@medalsocial/booking 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +21 -3
- package/dist/core/attempt-store.d.mts +1 -0
- package/dist/core/attempt-store.mjs +13 -0
- package/dist/core/attempt-store.mjs.map +1 -1
- package/dist/core/config.d.mts +37 -0
- package/dist/core/config.mjs +29 -0
- package/dist/core/config.mjs.map +1 -1
- package/dist/core/machine.d.mts +27 -1
- package/dist/core/machine.mjs +52 -4
- package/dist/core/machine.mjs.map +1 -1
- package/dist/next/medal.d.mts +8 -1
- package/dist/next/medal.mjs +16 -5
- package/dist/next/medal.mjs.map +1 -1
- package/dist/next/portal/medal-portal.mjs +1 -62
- package/dist/next/portal/medal-portal.mjs.map +1 -1
- package/dist/next/redact.mjs +76 -0
- package/dist/next/redact.mjs.map +1 -0
- package/dist/next/routes/create.mjs +67 -9
- package/dist/next/routes/create.mjs.map +1 -1
- package/dist/next/routes/shared.mjs +3 -0
- package/dist/next/routes/shared.mjs.map +1 -1
- package/dist/react/BookingWizard.mjs +139 -11
- package/dist/react/BookingWizard.mjs.map +1 -1
- package/dist/react/LoginSheet.d.mts +19 -1
- package/dist/react/LoginSheet.mjs +15 -1
- package/dist/react/LoginSheet.mjs.map +1 -1
- package/dist/react/Provider.d.mts +2 -1
- package/dist/react/Provider.mjs.map +1 -1
- package/dist/react/actions.d.mts +16 -5
- package/dist/react/actions.mjs +5 -1
- package/dist/react/actions.mjs.map +1 -1
- package/dist/react/index.d.mts +2 -2
- package/dist/react/kit.d.mts +6 -1
- package/dist/react/kit.mjs +4 -3
- package/dist/react/kit.mjs.map +1 -1
- package/dist/react/labels/login.d.mts +2 -0
- package/dist/react/labels/login.mjs +4 -2
- package/dist/react/labels/login.mjs.map +1 -1
- package/dist/react/labels/screens.mjs +58 -2
- package/dist/react/labels/screens.mjs.map +1 -1
- package/dist/react/labels/wizard.d.mts +8 -0
- package/dist/react/labels/wizard.mjs +6 -0
- package/dist/react/labels/wizard.mjs.map +1 -1
- package/dist/react/screen-labels.mjs +5 -0
- package/dist/react/screen-labels.mjs.map +1 -1
- package/dist/react/useBooking.d.mts +19 -1
- package/dist/react/useBooking.mjs +269 -42
- package/dist/react/useBooking.mjs.map +1 -1
- package/dist/react/wizard/adapters.mjs +66 -3
- package/dist/react/wizard/adapters.mjs.map +1 -1
- package/dist/react/wizard/index.d.mts +2 -2
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# @medalsocial/booking
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#197](https://github.com/Medal-Social/MedalSocial-SDK/pull/197) [`4312a91`](https://github.com/Medal-Social/MedalSocial-SDK/commit/4312a911666bf08dd68385bb5d168a43b9fa951e) Thanks [@adaadev](https://github.com/adaadev)! - Config `account.required`: booking only for a logged-in parent; `/create` answers 401 `accountRequired` without a session and forwards the portal session to Medal; the wizard's details step becomes «Fortsett med Vipps» / «Fortsett med e-post» until the parent is logged in (the e-mail then read-only, a session lost before submit shows the login again with the time kept), and the login page shows the same two buttons.
|
|
8
|
+
|
|
9
|
+
Requires `@medalsocial/meda` ^3.7.0 (this release's floor; the gate itself first needs 3.6.0 for the `emailCollapsed` login, `login.continueEmail`, `details.email.lockedHelp` and the `emailButton` slot).
|
|
10
|
+
|
|
11
|
+
- [#197](https://github.com/Medal-Social/MedalSocial-SDK/pull/197) [`4312a91`](https://github.com/Medal-Social/MedalSocial-SDK/commit/4312a911666bf08dd68385bb5d168a43b9fa951e) Thanks [@adaadev](https://github.com/adaadev)! - Config `screens`: opt-in screen features, every switch off by default. `recap` puts meda's `BookingRecap` above the details step (day, hours, who, stylist, total, «Endre», and a swap to another stylist free at the same minute when «first available» picked one); `soonest` and `dayFullness` turn on the time step's «Ledig snart» row and day marks; `summaryDetail` and `hideDisabledNext` give the bar a second line and a hint in place of a dead «Neste»; `firstAvailableFaces` and `stylistEdgeFade` draw «Første ledige» as the stylists' faces and fade the phone row; `guestParty` lets a guest book children and themselves together (step 1 opens with one child seated, in the server render too); `childMenuFirst` moves the grown-ups' groups below a child's age divider. New `wizard.summaryParts`, built-in nb/en words for every new key, and `screenLabels` returns every key required.
|
|
12
|
+
|
|
13
|
+
Requires `@medalsocial/meda` ^3.7.0.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- [#197](https://github.com/Medal-Social/MedalSocial-SDK/pull/197) [`4312a91`](https://github.com/Medal-Social/MedalSocial-SDK/commit/4312a911666bf08dd68385bb5d168a43b9fa951e) Thanks [@adaadev](https://github.com/adaadev)! - A login as another account never keeps the previous parent's child: only a shared person id keeps a seat (not a place, name and year match), and a saved child a family chip put on a guest line goes too. The guest chairs an account switch leaves keep their services when the parent steps back to the service step. A sent visit rebuilt after a lost session while nobody is logged in keeps saved children by id alone: the next login gets their own child back by id, anybody else a blank chair. New optional machine fields: `seatFamily.keep` and `unseatPeople.into`.
|
|
18
|
+
|
|
19
|
+
- [#200](https://github.com/Medal-Social/MedalSocial-SDK/pull/200) [`4c521ef`](https://github.com/Medal-Social/MedalSocial-SDK/commit/4c521ef89769ec85bf83bd4fb3339e712e99a8ee) Thanks [@adaadev](https://github.com/adaadev)! - A login whose profile could not be read no longer falls back to the parent the page arrived as: the family list goes, and the name, phone and e-mail that parent's profile filled in are cleared, so the next parent never books with the previous account's details. A field the visitor filled or changed stays theirs through any later logins, even typed back to the profile's value; a field the previous profile filled is emptied when the next profile has nothing for it. The children's seats that login let go keep their ids out of sight, so a parent who then logs in with a readable profile gets their own child back (by id only); anyone else keeps a blank chair.
|
|
20
|
+
|
|
21
|
+
- [#197](https://github.com/Medal-Social/MedalSocial-SDK/pull/197) [`4312a91`](https://github.com/Medal-Social/MedalSocial-SDK/commit/4312a911666bf08dd68385bb5d168a43b9fa951e) Thanks [@adaadev](https://github.com/adaadev)! - Picking a stylist again does not keep their previous open days after that read fails, and a retry is not marked settled while it is still in flight.
|
|
22
|
+
- Updated dependencies [[`4312a91`](https://github.com/Medal-Social/MedalSocial-SDK/commit/4312a911666bf08dd68385bb5d168a43b9fa951e)]:
|
|
23
|
+
- @medalsocial/sdk@1.14.0
|
|
24
|
+
|
|
3
25
|
## 0.2.1
|
|
4
26
|
|
|
5
27
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@ The booking product behind a Medal Social booking site: the wizard's rules,
|
|
|
4
4
|
the business's clock, money, phone and deep-link handling, browser stores,
|
|
5
5
|
and the customer-portal helpers — built on [`@medalsocial/sdk`](../sdk).
|
|
6
6
|
|
|
7
|
-
> **Status: pre-release (0.x,
|
|
8
|
-
> `/next` are
|
|
9
|
-
>
|
|
7
|
+
> **Status: pre-release (0.x), published on npm.** `/core`, `/react` and
|
|
8
|
+
> `/next` are stable enough to build a site on, but in 0.x a minor version may
|
|
9
|
+
> break; pin the exact version.
|
|
10
10
|
|
|
11
11
|
## Entry points
|
|
12
12
|
|
|
@@ -78,10 +78,28 @@ its own values in.
|
|
|
78
78
|
| `dayparts` | `formiddag` 0–12, `ettermiddag` 12–17, `kveld` 17–24 | Named parts of the day, contiguous from 0 to 24 |
|
|
79
79
|
| `portal` | disabled; cookie `booking_portal` (+ derived `_next`, `__Host-…_vipps_bind`, `_vipps_link`) | Customer portal switches and cookie names |
|
|
80
80
|
| `consent` | `{ termsUrl: null, marketing: null }` | The marketing sentence and its version are the site's own |
|
|
81
|
+
| `screens` | every switch `false` | Opt-in screen features, below; nothing changes for a site that sets none |
|
|
81
82
|
| `storageNamespace` | `medal` | Browser keys are `<ns>:booking:draft`, `<ns>:booking:attempt`, `<ns>:booking:rebook-who` |
|
|
82
83
|
| `ics` | `-//Medal Social//Booking//EN` | Calendar product id and UID domain |
|
|
83
84
|
| `labels` | built-in pack for `locale` | Partial copy overrides (`summary.pickTime`, `daypart.<key>`, …) |
|
|
84
85
|
|
|
86
|
+
### Opt-in screens (`screens`)
|
|
87
|
+
|
|
88
|
+
Each switch turns on one screen feature (needs `@medalsocial/meda` ^3.7). The
|
|
89
|
+
built-in packs carry their words; override them with `labels`.
|
|
90
|
+
|
|
91
|
+
| Switch | What the parent sees |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `recap` | The details step opens with what is being booked: a calendar leaf, the hours, each person's services and stylist, the total, «Endre» back to the time step, and — when «first available» picked one stylist while others were free at that minute — a one-tap swap (`recap.*`) |
|
|
94
|
+
| `soonest` | «Ledig snart» above the days: the three earliest starts across the window, one tap each, with who it would be with (`time.soonest.*`) |
|
|
95
|
+
| `dayFullness` | Each day chip shows how much room it has, or «fullt» for an open day searched for everyone (`time.dayChip.*`) |
|
|
96
|
+
| `summaryDetail` | The sticky bar's time and price on a second line, so a phone's ellipsis does not eat them |
|
|
97
|
+
| `hideDisabledNext` | The bar's «Neste» only while it does something; the step's hint in its place (`summary.hint.*`) |
|
|
98
|
+
| `firstAvailableFaces` | «Første ledige» drawn as the faces of the stylists who can do the basket |
|
|
99
|
+
| `stylistEdgeFade` | The phone's sideways stylist row fades at its edge |
|
|
100
|
+
| `guestParty` | A guest picks a number of children and «me too» instead of the chips, so a parent and child book together without an account; the step opens with one child seated (also in the server render) (`who.party.*`) |
|
|
101
|
+
| `childMenuFirst` | A child's service list leads with the children's and anyone's groups; grown-ups' groups move below the «usually not for this age» divider |
|
|
102
|
+
|
|
85
103
|
A site moving onto the package keeps its live sessions, drafts and cache
|
|
86
104
|
entries by passing the paths, cookie names and storage namespace it already
|
|
87
105
|
uses.
|
|
@@ -103,6 +103,7 @@ export interface AttemptStore {
|
|
|
103
103
|
readAttempt(): BookingAttempt;
|
|
104
104
|
rememberPending(attempt: BookingAttempt, pending: NonNullable<BookingAttempt['pending']>): void;
|
|
105
105
|
rememberConfirmed(attempt: BookingAttempt, confirmed: NonNullable<BookingAttempt['confirmed']>): void;
|
|
106
|
+
releasePending(attempt: BookingAttempt): void;
|
|
106
107
|
clearAttempt(): void;
|
|
107
108
|
}
|
|
108
109
|
/** The attempt store under `<namespace>:booking:attempt`. */
|
|
@@ -131,6 +131,18 @@ function createAttemptStore(namespace) {
|
|
|
131
131
|
});
|
|
132
132
|
}
|
|
133
133
|
/**
|
|
134
|
+
* Drop the pending submission, keep the nonce.
|
|
135
|
+
*
|
|
136
|
+
* For a submission refused because the parent's session ran out
|
|
137
|
+
* (`accountRequired`). Its body is not replayed — the next login may be
|
|
138
|
+
* somebody else, who is asked again — but an EARLIER try of it may have
|
|
139
|
+
* booked with its answer lost, and the same parent's resend after the login
|
|
140
|
+
* has to derive the same key to meet that booking instead of making another.
|
|
141
|
+
*/
|
|
142
|
+
function releasePending(attempt) {
|
|
143
|
+
write({ nonce: attempt.nonce });
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
134
146
|
* Forget the attempt entirely.
|
|
135
147
|
*
|
|
136
148
|
* For the failures that are certainly NOT «we do not know»: a slot that was
|
|
@@ -150,6 +162,7 @@ function createAttemptStore(namespace) {
|
|
|
150
162
|
readAttempt,
|
|
151
163
|
rememberPending,
|
|
152
164
|
rememberConfirmed,
|
|
165
|
+
releasePending,
|
|
153
166
|
clearAttempt
|
|
154
167
|
};
|
|
155
168
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"attempt-store.mjs","names":[],"sources":["../../src/core/attempt-store.ts"],"sourcesContent":["import type { WizardItem, WizardState } from './machine';\nimport type { BookingSubmission } from './types';\n\n/**\n * One booking attempt, kept where a page reload cannot reach it.\n *\n * The wizard's own state is React state, and React state does not survive a\n * reload — which would be unremarkable if reloading were not the visitor's ONLY\n * escape from a create request that never answers. Everything here exists for\n * that one moment: the engine has committed the appointment, the response was\n * lost, and the browser is now showing a spinner that will never stop.\n *\n * Three things have to outlive the page for that to end well:\n *\n * 1. The NONCE, which is half the create route's `Idempotency-Key`.\n * 2. The SUBMISSION it was sent with, which is the other half. A nonce alone is\n * not enough and it took a second look to see why: after the reload the\n * wizard restarts empty, and if the booking did commit then the slot the\n * visitor had is now occupied by their own appointment and gone from\n * availability. They cannot reconstruct the body even if they want to, so\n * they pick a different time — a different body, a different key, and a\n * second haircut for the same child.\n * 3. The CONFIRMATION, when one was received. The engine mints each manage\n * token once and returns the plaintext exactly once; it stores only a hash.\n * So a refresh of the confirmation screen before the visitor has saved the\n * calendar file used to take every manage link with it, permanently, for a\n * booking made without an e-mail address.\n *\n * `sessionStorage` and not `localStorage`: an attempt belongs to one tab. A\n * second tab booking a sibling is a different attempt and must not share a key.\n *\n * WHAT THIS PUTS AT REST, said plainly: a stored confirmation contains the\n * manage links, and a manage link is a bearer credential for that appointment.\n * It is the same credential that is already in the visitor's address bar, in\n * their e-mail, and in the calendar file they are about to download — so this\n * is not a new exposure so much as a shorter-lived copy of an existing one:\n * same origin, same tab, gone when the tab closes. The alternative is losing\n * the appointment's only handle to a refresh.\n */\n\n/** `<namespace>:booking:attempt`. */\nexport function attemptStorageKey(namespace: string): string {\n return `${namespace}:booking:attempt`;\n}\n\n/**\n * How long a stored attempt is worth restoring.\n *\n * The point is to survive a refresh, which happens in seconds. An hour is\n * generous for that and short enough that a visitor coming back to `/bestill`\n * later in the same session gets the wizard rather than last time's\n * confirmation.\n *\n * The TTL is no longer the ONLY way out: the confirmation now carries a\n * «Bestill ny time» control that clears the attempt and mints a fresh one, so a\n * parent booking a sibling does not have to wait an hour or find another tab.\n * The age still matters for the visitor who simply closes the screen and comes\n * back — they never press anything, and an hour-old confirmation is not what\n * they returned for.\n */\nexport const ATTEMPT_TTL_MS = 60 * 60 * 1000;\n\n/**\n * The visit as it was SUBMITTED, frozen when the POST left.\n *\n * «Bekreft»'s child-name inputs stay editable while the request is in flight, so\n * anything the confirmation reads off the live machine can describe a booking\n * that was never made: correct «Jonas» to «Emma» during a slow create and\n * Jonas's appointment is in Medal while the screen — and the calendar file —\n * both say Emma.\n */\nexport interface SubmittedVisit {\n items: WizardItem[];\n startTs: number;\n partyMode: WizardState['partyMode'];\n /** One resolved stylist per line, in basket order. */\n resourceIds: Array<string | null>;\n /**\n * Their NAMES, resolved at submit time and stored beside the ids.\n *\n * Not derivable after a reload: the names come from the stylist catalogue,\n * which the shell fetches for the basket's service — and a restored\n * confirmation has no basket, so that fetch never runs and every id resolves\n * to null. The refreshed card would lose the stylist from every line the\n * original card named.\n */\n stylistNames: Array<string | null>;\n}\n\nexport interface ConfirmedBooking {\n id: string;\n manageHref: string | null;\n}\n\nexport interface BookingAttempt {\n nonce: string;\n /** Sent, with no answer yet seen. Replayed on the next load. */\n pending?: { submission: BookingSubmission; submitted: SubmittedVisit };\n /** Answered. Kept so a refresh cannot take the manage links with it. */\n confirmed?: { bookings: ConfirmedBooking[]; submitted: SubmittedVisit };\n}\n\ninterface StoredAttempt extends BookingAttempt {\n /** When this record was last written, for `ATTEMPT_TTL_MS`. */\n at: number;\n}\n\nexport interface AttemptStore {\n readonly ATTEMPT_STORAGE_KEY: string;\n readAttempt(): BookingAttempt;\n rememberPending(attempt: BookingAttempt, pending: NonNullable<BookingAttempt['pending']>): void;\n rememberConfirmed(\n attempt: BookingAttempt,\n confirmed: NonNullable<BookingAttempt['confirmed']>\n ): void;\n clearAttempt(): void;\n}\n\n/** The attempt store under `<namespace>:booking:attempt`. */\nexport function createAttemptStore(namespace: string): AttemptStore {\n const STORAGE_KEY = attemptStorageKey(namespace);\n\n /**\n * Every path through this module falls back to «no stored attempt» rather than\n * throwing.\n *\n * There is no `sessionStorage` during the server render, Safari's private mode\n * has historically thrown on write, and a visitor may simply have storage\n * turned off. Losing the idempotency guarantee is bad; refusing to let somebody\n * book a haircut because their browser will not hold a string is worse.\n */\n function readStored(): StoredAttempt | null {\n if (typeof window === 'undefined') return null;\n try {\n const raw = window.sessionStorage.getItem(STORAGE_KEY);\n if (!raw) return null;\n const parsed = JSON.parse(raw) as StoredAttempt;\n if (typeof parsed?.nonce !== 'string' || typeof parsed?.at !== 'number') return null;\n if (Date.now() - parsed.at > ATTEMPT_TTL_MS) return null;\n return parsed;\n } catch {\n return null;\n }\n }\n\n function write(attempt: BookingAttempt): void {\n if (typeof window === 'undefined') return;\n try {\n const stored: StoredAttempt = { ...attempt, at: Date.now() };\n window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stored));\n } catch {\n // The attempt goes on without the guarantee. See the note above.\n }\n }\n\n /**\n * The attempt this tab is on — the one already stored, or a fresh one.\n *\n * Reading MINTS and stores when there is nothing to read, so the nonce exists\n * from the moment the wizard mounts rather than from the moment somebody\n * presses «Bekreft time». A nonce created at submit would be created again by\n * the reload it is supposed to survive.\n */\n function readAttempt(): BookingAttempt {\n const stored = readStored();\n if (stored !== null) {\n const { at: _at, ...attempt } = stored;\n return attempt;\n }\n const fresh: BookingAttempt = { nonce: crypto.randomUUID() };\n write(fresh);\n return fresh;\n }\n\n /** Record what was just sent, BEFORE it is sent. A body stored after the\n * response is a body that is not there when the response never comes. */\n function rememberPending(\n attempt: BookingAttempt,\n pending: NonNullable<BookingAttempt['pending']>\n ): void {\n write({ nonce: attempt.nonce, pending });\n }\n\n /**\n * Record the answer, and drop the pending submission with it.\n *\n * Order matters on the next load: a stored confirmation is shown, a stored\n * pending submission is replayed. Leaving both would replay a booking that has\n * already been confirmed — harmless, because the key makes it a replay, but a\n * needless round trip on every reload of the confirmation.\n */\n function rememberConfirmed(\n attempt: BookingAttempt,\n confirmed: NonNullable<BookingAttempt['confirmed']>\n ): void {\n write({ nonce: attempt.nonce, confirmed });\n }\n\n /**\n * Forget the attempt entirely.\n *\n * For the failures that are certainly NOT «we do not know»: a slot that was\n * taken, or a body the engine refused. Both mean no appointment was made and\n * the visitor is about to build a different one, so replaying this on the next\n * load would resend a submission that has already been answered — with a\n * «Bekreft time» nobody pressed.\n */\n function clearAttempt(): void {\n if (typeof window === 'undefined') return;\n try {\n window.sessionStorage.removeItem(STORAGE_KEY);\n } catch {\n // Nothing to do, and nothing worth failing a booking over.\n }\n }\n\n return {\n ATTEMPT_STORAGE_KEY: STORAGE_KEY,\n readAttempt,\n rememberPending,\n rememberConfirmed,\n clearAttempt,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCA,SAAgB,kBAAkB,WAA2B;CAC3D,OAAO,GAAG,UAAU;AACtB;;;;;;;;;;;;;;;;AAiBA,MAAa,iBAAiB;;AA2D9B,SAAgB,mBAAmB,WAAiC;CAClE,MAAM,cAAc,kBAAkB,SAAS;;;;;;;;;;CAW/C,SAAS,aAAmC;EAC1C,IAAI,OAAO,WAAW,aAAa,OAAO;EAC1C,IAAI;GACF,MAAM,MAAM,OAAO,eAAe,QAAQ,WAAW;GACrD,IAAI,CAAC,KAAK,OAAO;GACjB,MAAM,SAAS,KAAK,MAAM,GAAG;GAC7B,IAAI,OAAO,QAAQ,UAAU,YAAY,OAAO,QAAQ,OAAO,UAAU,OAAO;GAChF,IAAI,KAAK,IAAI,IAAI,OAAO,KAAA,MAAqB,OAAO;GACpD,OAAO;EACT,QAAQ;GACN,OAAO;EACT;CACF;CAEA,SAAS,MAAM,SAA+B;EAC5C,IAAI,OAAO,WAAW,aAAa;EACnC,IAAI;GACF,MAAM,SAAwB;IAAE,GAAG;IAAS,IAAI,KAAK,IAAI;GAAE;GAC3D,OAAO,eAAe,QAAQ,aAAa,KAAK,UAAU,MAAM,CAAC;EACnE,QAAQ,CAER;CACF;;;;;;;;;CAUA,SAAS,cAA8B;EACrC,MAAM,SAAS,WAAW;EAC1B,IAAI,WAAW,MAAM;GACnB,MAAM,EAAE,IAAI,KAAK,GAAG,YAAY;GAChC,OAAO;EACT;EACA,MAAM,QAAwB,EAAE,OAAO,OAAO,WAAW,EAAE;EAC3D,MAAM,KAAK;EACX,OAAO;CACT;;;CAIA,SAAS,gBACP,SACA,SACM;EACN,MAAM;GAAE,OAAO,QAAQ;GAAO;EAAQ,CAAC;CACzC;;;;;;;;;CAUA,SAAS,kBACP,SACA,WACM;EACN,MAAM;GAAE,OAAO,QAAQ;GAAO;EAAU,CAAC;CAC3C;;;;;;;;;;CAWA,SAAS,eAAqB;EAC5B,IAAI,OAAO,WAAW,aAAa;EACnC,IAAI;GACF,OAAO,eAAe,WAAW,WAAW;EAC9C,QAAQ,CAER;CACF;CAEA,OAAO;EACL,qBAAqB;EACrB;EACA;EACA;EACA;CACF;AACF"}
|
|
1
|
+
{"version":3,"file":"attempt-store.mjs","names":[],"sources":["../../src/core/attempt-store.ts"],"sourcesContent":["import type { WizardItem, WizardState } from './machine';\nimport type { BookingSubmission } from './types';\n\n/**\n * One booking attempt, kept where a page reload cannot reach it.\n *\n * The wizard's own state is React state, and React state does not survive a\n * reload — which would be unremarkable if reloading were not the visitor's ONLY\n * escape from a create request that never answers. Everything here exists for\n * that one moment: the engine has committed the appointment, the response was\n * lost, and the browser is now showing a spinner that will never stop.\n *\n * Three things have to outlive the page for that to end well:\n *\n * 1. The NONCE, which is half the create route's `Idempotency-Key`.\n * 2. The SUBMISSION it was sent with, which is the other half. A nonce alone is\n * not enough and it took a second look to see why: after the reload the\n * wizard restarts empty, and if the booking did commit then the slot the\n * visitor had is now occupied by their own appointment and gone from\n * availability. They cannot reconstruct the body even if they want to, so\n * they pick a different time — a different body, a different key, and a\n * second haircut for the same child.\n * 3. The CONFIRMATION, when one was received. The engine mints each manage\n * token once and returns the plaintext exactly once; it stores only a hash.\n * So a refresh of the confirmation screen before the visitor has saved the\n * calendar file used to take every manage link with it, permanently, for a\n * booking made without an e-mail address.\n *\n * `sessionStorage` and not `localStorage`: an attempt belongs to one tab. A\n * second tab booking a sibling is a different attempt and must not share a key.\n *\n * WHAT THIS PUTS AT REST, said plainly: a stored confirmation contains the\n * manage links, and a manage link is a bearer credential for that appointment.\n * It is the same credential that is already in the visitor's address bar, in\n * their e-mail, and in the calendar file they are about to download — so this\n * is not a new exposure so much as a shorter-lived copy of an existing one:\n * same origin, same tab, gone when the tab closes. The alternative is losing\n * the appointment's only handle to a refresh.\n */\n\n/** `<namespace>:booking:attempt`. */\nexport function attemptStorageKey(namespace: string): string {\n return `${namespace}:booking:attempt`;\n}\n\n/**\n * How long a stored attempt is worth restoring.\n *\n * The point is to survive a refresh, which happens in seconds. An hour is\n * generous for that and short enough that a visitor coming back to `/bestill`\n * later in the same session gets the wizard rather than last time's\n * confirmation.\n *\n * The TTL is no longer the ONLY way out: the confirmation now carries a\n * «Bestill ny time» control that clears the attempt and mints a fresh one, so a\n * parent booking a sibling does not have to wait an hour or find another tab.\n * The age still matters for the visitor who simply closes the screen and comes\n * back — they never press anything, and an hour-old confirmation is not what\n * they returned for.\n */\nexport const ATTEMPT_TTL_MS = 60 * 60 * 1000;\n\n/**\n * The visit as it was SUBMITTED, frozen when the POST left.\n *\n * «Bekreft»'s child-name inputs stay editable while the request is in flight, so\n * anything the confirmation reads off the live machine can describe a booking\n * that was never made: correct «Jonas» to «Emma» during a slow create and\n * Jonas's appointment is in Medal while the screen — and the calendar file —\n * both say Emma.\n */\nexport interface SubmittedVisit {\n items: WizardItem[];\n startTs: number;\n partyMode: WizardState['partyMode'];\n /** One resolved stylist per line, in basket order. */\n resourceIds: Array<string | null>;\n /**\n * Their NAMES, resolved at submit time and stored beside the ids.\n *\n * Not derivable after a reload: the names come from the stylist catalogue,\n * which the shell fetches for the basket's service — and a restored\n * confirmation has no basket, so that fetch never runs and every id resolves\n * to null. The refreshed card would lose the stylist from every line the\n * original card named.\n */\n stylistNames: Array<string | null>;\n}\n\nexport interface ConfirmedBooking {\n id: string;\n manageHref: string | null;\n}\n\nexport interface BookingAttempt {\n nonce: string;\n /** Sent, with no answer yet seen. Replayed on the next load. */\n pending?: { submission: BookingSubmission; submitted: SubmittedVisit };\n /** Answered. Kept so a refresh cannot take the manage links with it. */\n confirmed?: { bookings: ConfirmedBooking[]; submitted: SubmittedVisit };\n}\n\ninterface StoredAttempt extends BookingAttempt {\n /** When this record was last written, for `ATTEMPT_TTL_MS`. */\n at: number;\n}\n\nexport interface AttemptStore {\n readonly ATTEMPT_STORAGE_KEY: string;\n readAttempt(): BookingAttempt;\n rememberPending(attempt: BookingAttempt, pending: NonNullable<BookingAttempt['pending']>): void;\n rememberConfirmed(\n attempt: BookingAttempt,\n confirmed: NonNullable<BookingAttempt['confirmed']>\n ): void;\n releasePending(attempt: BookingAttempt): void;\n clearAttempt(): void;\n}\n\n/** The attempt store under `<namespace>:booking:attempt`. */\nexport function createAttemptStore(namespace: string): AttemptStore {\n const STORAGE_KEY = attemptStorageKey(namespace);\n\n /**\n * Every path through this module falls back to «no stored attempt» rather than\n * throwing.\n *\n * There is no `sessionStorage` during the server render, Safari's private mode\n * has historically thrown on write, and a visitor may simply have storage\n * turned off. Losing the idempotency guarantee is bad; refusing to let somebody\n * book a haircut because their browser will not hold a string is worse.\n */\n function readStored(): StoredAttempt | null {\n if (typeof window === 'undefined') return null;\n try {\n const raw = window.sessionStorage.getItem(STORAGE_KEY);\n if (!raw) return null;\n const parsed = JSON.parse(raw) as StoredAttempt;\n if (typeof parsed?.nonce !== 'string' || typeof parsed?.at !== 'number') return null;\n if (Date.now() - parsed.at > ATTEMPT_TTL_MS) return null;\n return parsed;\n } catch {\n return null;\n }\n }\n\n function write(attempt: BookingAttempt): void {\n if (typeof window === 'undefined') return;\n try {\n const stored: StoredAttempt = { ...attempt, at: Date.now() };\n window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stored));\n } catch {\n // The attempt goes on without the guarantee. See the note above.\n }\n }\n\n /**\n * The attempt this tab is on — the one already stored, or a fresh one.\n *\n * Reading MINTS and stores when there is nothing to read, so the nonce exists\n * from the moment the wizard mounts rather than from the moment somebody\n * presses «Bekreft time». A nonce created at submit would be created again by\n * the reload it is supposed to survive.\n */\n function readAttempt(): BookingAttempt {\n const stored = readStored();\n if (stored !== null) {\n const { at: _at, ...attempt } = stored;\n return attempt;\n }\n const fresh: BookingAttempt = { nonce: crypto.randomUUID() };\n write(fresh);\n return fresh;\n }\n\n /** Record what was just sent, BEFORE it is sent. A body stored after the\n * response is a body that is not there when the response never comes. */\n function rememberPending(\n attempt: BookingAttempt,\n pending: NonNullable<BookingAttempt['pending']>\n ): void {\n write({ nonce: attempt.nonce, pending });\n }\n\n /**\n * Record the answer, and drop the pending submission with it.\n *\n * Order matters on the next load: a stored confirmation is shown, a stored\n * pending submission is replayed. Leaving both would replay a booking that has\n * already been confirmed — harmless, because the key makes it a replay, but a\n * needless round trip on every reload of the confirmation.\n */\n function rememberConfirmed(\n attempt: BookingAttempt,\n confirmed: NonNullable<BookingAttempt['confirmed']>\n ): void {\n write({ nonce: attempt.nonce, confirmed });\n }\n\n /**\n * Drop the pending submission, keep the nonce.\n *\n * For a submission refused because the parent's session ran out\n * (`accountRequired`). Its body is not replayed — the next login may be\n * somebody else, who is asked again — but an EARLIER try of it may have\n * booked with its answer lost, and the same parent's resend after the login\n * has to derive the same key to meet that booking instead of making another.\n */\n function releasePending(attempt: BookingAttempt): void {\n write({ nonce: attempt.nonce });\n }\n\n /**\n * Forget the attempt entirely.\n *\n * For the failures that are certainly NOT «we do not know»: a slot that was\n * taken, or a body the engine refused. Both mean no appointment was made and\n * the visitor is about to build a different one, so replaying this on the next\n * load would resend a submission that has already been answered — with a\n * «Bekreft time» nobody pressed.\n */\n function clearAttempt(): void {\n if (typeof window === 'undefined') return;\n try {\n window.sessionStorage.removeItem(STORAGE_KEY);\n } catch {\n // Nothing to do, and nothing worth failing a booking over.\n }\n }\n\n return {\n ATTEMPT_STORAGE_KEY: STORAGE_KEY,\n readAttempt,\n rememberPending,\n rememberConfirmed,\n releasePending,\n clearAttempt,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCA,SAAgB,kBAAkB,WAA2B;CAC3D,OAAO,GAAG,UAAU;AACtB;;;;;;;;;;;;;;;;AAiBA,MAAa,iBAAiB;;AA4D9B,SAAgB,mBAAmB,WAAiC;CAClE,MAAM,cAAc,kBAAkB,SAAS;;;;;;;;;;CAW/C,SAAS,aAAmC;EAC1C,IAAI,OAAO,WAAW,aAAa,OAAO;EAC1C,IAAI;GACF,MAAM,MAAM,OAAO,eAAe,QAAQ,WAAW;GACrD,IAAI,CAAC,KAAK,OAAO;GACjB,MAAM,SAAS,KAAK,MAAM,GAAG;GAC7B,IAAI,OAAO,QAAQ,UAAU,YAAY,OAAO,QAAQ,OAAO,UAAU,OAAO;GAChF,IAAI,KAAK,IAAI,IAAI,OAAO,KAAA,MAAqB,OAAO;GACpD,OAAO;EACT,QAAQ;GACN,OAAO;EACT;CACF;CAEA,SAAS,MAAM,SAA+B;EAC5C,IAAI,OAAO,WAAW,aAAa;EACnC,IAAI;GACF,MAAM,SAAwB;IAAE,GAAG;IAAS,IAAI,KAAK,IAAI;GAAE;GAC3D,OAAO,eAAe,QAAQ,aAAa,KAAK,UAAU,MAAM,CAAC;EACnE,QAAQ,CAER;CACF;;;;;;;;;CAUA,SAAS,cAA8B;EACrC,MAAM,SAAS,WAAW;EAC1B,IAAI,WAAW,MAAM;GACnB,MAAM,EAAE,IAAI,KAAK,GAAG,YAAY;GAChC,OAAO;EACT;EACA,MAAM,QAAwB,EAAE,OAAO,OAAO,WAAW,EAAE;EAC3D,MAAM,KAAK;EACX,OAAO;CACT;;;CAIA,SAAS,gBACP,SACA,SACM;EACN,MAAM;GAAE,OAAO,QAAQ;GAAO;EAAQ,CAAC;CACzC;;;;;;;;;CAUA,SAAS,kBACP,SACA,WACM;EACN,MAAM;GAAE,OAAO,QAAQ;GAAO;EAAU,CAAC;CAC3C;;;;;;;;;;CAWA,SAAS,eAAe,SAA+B;EACrD,MAAM,EAAE,OAAO,QAAQ,MAAM,CAAC;CAChC;;;;;;;;;;CAWA,SAAS,eAAqB;EAC5B,IAAI,OAAO,WAAW,aAAa;EACnC,IAAI;GACF,OAAO,eAAe,WAAW,WAAW;EAC9C,QAAQ,CAER;CACF;CAEA,OAAO;EACL,qBAAqB;EACrB;EACA;EACA;EACA;EACA;CACF;AACF"}
|
package/dist/core/config.d.mts
CHANGED
|
@@ -123,6 +123,43 @@ export interface BookingConfig {
|
|
|
123
123
|
prefixes: string[];
|
|
124
124
|
};
|
|
125
125
|
};
|
|
126
|
+
/**
|
|
127
|
+
* Booking only for a logged-in parent: the details step becomes the login
|
|
128
|
+
* until there is a session, and `/create` refuses without one.
|
|
129
|
+
*/
|
|
130
|
+
account: {
|
|
131
|
+
required: boolean;
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* Opt-in screen features, all off by default so a site's wizard only
|
|
135
|
+
* changes when it asks:
|
|
136
|
+
* - `recap` — the details step opens with what is being booked (day, hours,
|
|
137
|
+
* who, stylist, total, «edit», and a swap to another stylist free at the
|
|
138
|
+
* same minute when «first available» picked one).
|
|
139
|
+
* - `soonest` — the time step leads with the earliest free starts.
|
|
140
|
+
* - `dayFullness` — each day chip shows how much room the day has.
|
|
141
|
+
* - `summaryDetail` — the sticky bar's time and price on a second line.
|
|
142
|
+
* - `hideDisabledNext` — the bar's «next» only while it does something.
|
|
143
|
+
* - `firstAvailableFaces` — «first available» drawn as the qualified
|
|
144
|
+
* stylists' faces.
|
|
145
|
+
* - `stylistEdgeFade` — the phone stylist row fades at its edge.
|
|
146
|
+
* - `guestParty` — a guest picks a number of children and «me too» instead
|
|
147
|
+
* of the chips, so a parent and child book together without an account.
|
|
148
|
+
* - `childMenuFirst` — a child's service list leads with the children's and
|
|
149
|
+
* anyone's groups; the grown-ups' groups move below the «usually not for
|
|
150
|
+
* this age» divider, still one tap away.
|
|
151
|
+
*/
|
|
152
|
+
screens: {
|
|
153
|
+
recap: boolean;
|
|
154
|
+
soonest: boolean;
|
|
155
|
+
dayFullness: boolean;
|
|
156
|
+
summaryDetail: boolean;
|
|
157
|
+
hideDisabledNext: boolean;
|
|
158
|
+
firstAvailableFaces: boolean;
|
|
159
|
+
stylistEdgeFade: boolean;
|
|
160
|
+
guestParty: boolean;
|
|
161
|
+
childMenuFirst: boolean;
|
|
162
|
+
};
|
|
126
163
|
consent: {
|
|
127
164
|
termsUrl: string | null;
|
|
128
165
|
/** `null` = no marketing box. */
|
package/dist/core/config.mjs
CHANGED
|
@@ -96,6 +96,18 @@ function defaults() {
|
|
|
96
96
|
methods: ["email_code"],
|
|
97
97
|
cookieName: "booking_portal"
|
|
98
98
|
},
|
|
99
|
+
account: { required: false },
|
|
100
|
+
screens: {
|
|
101
|
+
recap: false,
|
|
102
|
+
soonest: false,
|
|
103
|
+
dayFullness: false,
|
|
104
|
+
summaryDetail: false,
|
|
105
|
+
hideDisabledNext: false,
|
|
106
|
+
firstAvailableFaces: false,
|
|
107
|
+
stylistEdgeFade: false,
|
|
108
|
+
guestParty: false,
|
|
109
|
+
childMenuFirst: false
|
|
110
|
+
},
|
|
99
111
|
consent: {
|
|
100
112
|
termsUrl: null,
|
|
101
113
|
marketing: null
|
|
@@ -270,6 +282,18 @@ const schema = z.object({
|
|
|
270
282
|
prefixes: z.array(prefix)
|
|
271
283
|
})
|
|
272
284
|
}),
|
|
285
|
+
account: z.object({ required: z.boolean() }),
|
|
286
|
+
screens: z.object({
|
|
287
|
+
recap: z.boolean(),
|
|
288
|
+
soonest: z.boolean(),
|
|
289
|
+
dayFullness: z.boolean(),
|
|
290
|
+
summaryDetail: z.boolean(),
|
|
291
|
+
hideDisabledNext: z.boolean(),
|
|
292
|
+
firstAvailableFaces: z.boolean(),
|
|
293
|
+
stylistEdgeFade: z.boolean(),
|
|
294
|
+
guestParty: z.boolean(),
|
|
295
|
+
childMenuFirst: z.boolean()
|
|
296
|
+
}),
|
|
273
297
|
consent: z.object({
|
|
274
298
|
termsUrl: z.string().min(1).nullable(),
|
|
275
299
|
marketing: z.object({
|
|
@@ -336,6 +360,11 @@ const schema = z.object({
|
|
|
336
360
|
path: ["portal", "methods"],
|
|
337
361
|
message: "an enabled portal needs a login method"
|
|
338
362
|
});
|
|
363
|
+
if (config.account.required && !config.portal.enabled) ctx.addIssue({
|
|
364
|
+
code: "custom",
|
|
365
|
+
path: ["account", "required"],
|
|
366
|
+
message: "account.required needs portal.enabled"
|
|
367
|
+
});
|
|
339
368
|
});
|
|
340
369
|
function deepFreeze(value) {
|
|
341
370
|
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
|
package/dist/core/config.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.mjs","names":[],"sources":["../../src/core/config.ts"],"sourcesContent":["/**\n * What a site tells the booking package about itself.\n *\n * `BookingConfig` is CLIENT-SAFE by construction: it is serialised into the\n * page as a prop, so it never holds a key, a session secret or a cache handle.\n * Those live in `BookingServerOptions`, which only `/next` knows (P4).\n *\n * The package reads no environment variable and no CMS. A site resolves its\n * own switches (a handoff URL, whether accounts are on, the contact block) and\n * passes the answers in.\n *\n * Every field but `timeZone` has a default. `resolveBookingConfig` fills them,\n * validates the whole object with zod and freezes it, so a config that reached\n * a factory is one that passed.\n */\n\nimport { z } from 'zod';\nimport type { BookingLabels } from './labels';\n\n/** A service group, in display order. Medal's free-string `category` maps onto `key`. */\nexport interface BookingCategory {\n key: string;\n /** Who the group is for. `child` is the kids' path: guest children, `?antall=`, the held link service. */\n audience: 'child' | 'adult' | 'any';\n /**\n * For a child's group: which adult group a service «grows up» into once the\n * child has outgrown the kids' menu. A plain key maps every service in the\n * group; a list maps by a word in the service name, first match wins\n * (`{ nameIncludes: 'gutt', category: 'herre' }`). Case-insensitive.\n */\n adultEquivalent?: string | ReadonlyArray<{ nameIncludes: string; category: string }>;\n}\n\n/** A named part of the day on the business's clock, in local hours `[from, to)`. */\nexport interface BookingDaypart {\n key: string;\n from: number;\n to: number;\n}\n\nexport interface BookingConfig {\n /** IANA zone the business keeps its hours in. Required. */\n timeZone: string;\n /** BCP-47, for every `Intl` formatter and the label pack. */\n locale: string;\n /** ISO 4217. Amounts stay in minor units (øre, pence) on the wire. */\n currency: string;\n /** `NO` is the only strict rule set in 0.x; every other country is `loose`. */\n phone: { country: string; validate: 'strict' | 'loose' };\n /** URLs this site serves. */\n paths: {\n booking: string;\n /** Prefix of the manage page; the token is the next segment. */\n manage: string;\n /** `null` = no customer portal. */\n portal: string | null;\n portalLogin: string;\n api: string;\n portalApi: string;\n avatar: string;\n };\n /**\n * The query keys a link may carry into the booking page.\n *\n * `category`, `service`, `stylist`, `party` and `who` are the marketing\n * deep-link keys; `rebookService` / `rebookStylist` are the portal's «book\n * again» link, read by the wizard's own prefill; `resume` marks a return\n * from an external login; `time` is reserved for a pre-picked start.\n */\n query: {\n category: string;\n service: string;\n stylist: string;\n party: string;\n who: string;\n time: string;\n resume: string;\n rebookService: string;\n rebookStylist: string;\n };\n /** The values `query.who` accepts. */\n whoValues: { child: string; adult: string };\n /** Service groups, in display order. */\n categories: BookingCategory[];\n /** Where an unknown Medal category lands. Must be one of `categories`. */\n fallbackCategory: string;\n /** Step 1's rules. */\n party: {\n maxPeople: number;\n allowParallel: boolean;\n askWhoFirst: boolean;\n /**\n * How many services one person may book as a single visit (1–4; Medal's\n * cap is 4). `1` — the default — is the one-tap service step: a tap\n * answers it. Above `1`, `<BookingWizard>` draws the multi-select step:\n * each person ticks up to this many, done back to back as one visit.\n */\n maxServicesPerPerson: number;\n };\n /** The bookable window and the page's prefetch. */\n window: { rangeDays: number; prefetchLimit: number; prefetchCategory: string | null };\n /** Named day parts for the time step, contiguous from 0 to 24. */\n dayparts: BookingDaypart[];\n /** Set = the booking path hands off (307) to this URL instead of rendering. */\n handoffUrl: string | null;\n /** The business's own contact, for «call us» and the unavailable screen. */\n contact: { phone: string | null; address: string | null; name: string };\n portal: {\n enabled: boolean;\n methods: Array<'email_code' | 'vipps'>;\n /** The session cookie. */\n cookieName: string;\n /** Where a login that left the site (Vipps) returns to; read once. */\n nextCookieName: string;\n /** Must be `__Host-`-prefixed: Secure, `Path=/`, no `Domain`. */\n vippsBindingCookieName: string;\n vippsLinkCookieName: string;\n /**\n * Where a finished login may send the visitor. `exact` matches the\n * pathname only (the booking page, so a deeper manage URL never\n * qualifies); `prefixes` matches anything below.\n */\n returnPaths: { exact: string[]; prefixes: string[] };\n };\n consent: {\n termsUrl: string | null;\n /** `null` = no marketing box. */\n marketing: { text: string; version: string } | null;\n };\n /** Namespaces browser storage keys: `<ns>:booking:draft`, … */\n storageNamespace: string;\n /**\n * `prodId` stamps every calendar file. `uidDomain` is for UIDs the package\n * mints itself (`/react`, 0.1.0); a booking id passed in as `uid` is used as\n * is, so a calendar entry made before a site moved onto the package is still\n * the same entry after it.\n */\n ics: { prodId: string; uidDomain: string };\n monitoring: { enabled: boolean; sampleRate: number };\n /** Copy, merged over the built-in pack for `locale`. */\n labels?: Partial<BookingLabels>;\n}\n\ntype DeepPartial<T> = T extends readonly unknown[]\n ? T\n : T extends object\n ? { [K in keyof T]?: DeepPartial<T[K]> }\n : T;\n\n/** What a site passes: `timeZone`, and whatever it wants to change. */\nexport type BookingConfigInput = { timeZone: string } & DeepPartial<\n Omit<BookingConfig, 'timeZone'>\n>;\n\nconst DEFAULT_PATHS: BookingConfig['paths'] = {\n booking: '/bestill',\n manage: '/bestill/administrer',\n portal: '/min-side',\n portalLogin: '/min-side/logg-inn',\n api: '/api/booking',\n portalApi: '/api/portal',\n avatar: '/api/booking/avatar',\n};\n\n/** Everything but `timeZone` (required) and the portal cookie names (derived). */\nfunction defaults(): Omit<BookingConfig, 'timeZone' | 'portal'> & {\n portal: Omit<\n BookingConfig['portal'],\n 'nextCookieName' | 'vippsBindingCookieName' | 'vippsLinkCookieName' | 'returnPaths'\n >;\n} {\n return {\n locale: 'nb-NO',\n currency: 'NOK',\n phone: { country: 'NO', validate: 'strict' },\n paths: { ...DEFAULT_PATHS },\n query: {\n category: 'kategori',\n service: 'tjeneste',\n stylist: 'frisor',\n party: 'antall',\n who: 'hvem',\n time: 'tid',\n resume: 'resume',\n rebookService: 'service',\n rebookStylist: 'stylist',\n },\n whoValues: { child: 'barn', adult: 'voksen' },\n categories: [\n { key: 'barn', audience: 'child' },\n { key: 'annet', audience: 'any' },\n ],\n fallbackCategory: 'annet',\n party: { maxPeople: 3, allowParallel: true, askWhoFirst: true, maxServicesPerPerson: 1 },\n window: { rangeDays: 7, prefetchLimit: 4, prefetchCategory: null },\n dayparts: [\n { key: 'formiddag', from: 0, to: 12 },\n { key: 'ettermiddag', from: 12, to: 17 },\n { key: 'kveld', from: 17, to: 24 },\n ],\n handoffUrl: null,\n contact: { phone: null, address: null, name: '' },\n portal: { enabled: false, methods: ['email_code'], cookieName: 'booking_portal' },\n consent: { termsUrl: null, marketing: null },\n storageNamespace: 'medal',\n ics: { prodId: '-//Medal Social//Booking//EN', uidDomain: 'booking.invalid' },\n monitoring: { enabled: true, sampleRate: 1 },\n };\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/**\n * Objects merge key by key; arrays, scalars and `null` replace. Only\n * `undefined` keeps the default — `paths.portal: null` means «no portal».\n */\nfunction merge(base: unknown, patch: unknown): unknown {\n if (patch === undefined) return base;\n if (!isRecord(base) || !isRecord(patch)) return patch;\n const out: Record<string, unknown> = { ...base };\n for (const [key, value] of Object.entries(patch)) out[key] = merge(base[key], value);\n return out;\n}\n\nfunction validTimeZone(value: string): boolean {\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: value });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction validLocale(value: string): boolean {\n try {\n return Intl.getCanonicalLocales(value).length === 1;\n } catch {\n return false;\n }\n}\n\nconst path = z.string().regex(/^\\/[^\\s?#]*$/, 'must be a root-relative path');\nconst key = z.string().regex(/^[\\w-]{1,64}$/, 'must be 1–64 word characters');\n/** Group keys and `?hvem=` values are matched against a lower-cased query value. */\nconst lowerKey = z.string().regex(/^[a-z0-9_-]{1,64}$/, 'must be 1–64 lower-case word characters');\n/** A prefix must end at a segment boundary, or `/flow` would also admit `/flow-evil`. */\nconst prefix = path.refine((value) => value.endsWith('/'), 'a prefix must end with /');\n/** The longest cookie name the config takes. */\nconst MAX_COOKIE_NAME = 128;\n/** RFC 6265 token characters, which is every name a browser keeps intact. */\nconst cookieName = z.string().regex(/^[!#$%&'*+\\-.^_`|~0-9A-Za-z]{1,128}$/);\n\n/**\n * How each secondary portal cookie name derives from `portal.cookieName` when\n * the site sets none — the prefix and suffix, so a too-long base can be\n * reported against the name the site actually wrote.\n */\nconst DERIVED_COOKIE_NAMES = {\n nextCookieName: { prefix: '', suffix: '_next' },\n vippsBindingCookieName: { prefix: '__Host-', suffix: '_vipps_bind' },\n vippsLinkCookieName: { prefix: '', suffix: '_vipps_link' },\n} as const;\n\ntype DerivedCookieName = keyof typeof DERIVED_COOKIE_NAMES;\n\n/**\n * A derived name that would be too long, said about `portal.cookieName`\n * (which the site wrote) rather than a name it never set. `null` when every\n * derived name fits — the schema then judges the characters as usual — and\n * when the base is not a string at all (a runtime input the types did not\n * catch): the schema's own issue on `portal.cookieName` says so.\n */\nfunction derivedCookieNameIssue(\n session: unknown,\n derived: readonly DerivedCookieName[]\n): { path: string; message: string } | null {\n if (typeof session !== 'string') return null;\n const tooLong = derived.filter((name) => {\n const { prefix, suffix } = DERIVED_COOKIE_NAMES[name];\n return prefix.length + session.length + suffix.length > MAX_COOKIE_NAME;\n });\n if (tooLong.length === 0) return null;\n const room = Math.min(\n ...tooLong.map((name) => {\n const { prefix, suffix } = DERIVED_COOKIE_NAMES[name];\n return MAX_COOKIE_NAME - prefix.length - suffix.length;\n })\n );\n return {\n path: 'portal.cookieName',\n message: `must be at most ${room} characters, or the derived ${tooLong\n .map((name) => `portal.${name}`)\n .join(', ')} would exceed ${MAX_COOKIE_NAME}; shorten it or set ${\n tooLong.length === 1 ? 'that name' : 'those names'\n } explicitly`,\n };\n}\n\nconst schema = z\n .object({\n timeZone: z.string().refine(validTimeZone, 'must be an IANA time zone'),\n locale: z.string().refine(validLocale, 'must be a BCP-47 locale'),\n currency: z.string().regex(/^[A-Z]{3}$/, 'must be an ISO 4217 code'),\n phone: z.object({\n country: z.string().regex(/^[A-Z]{2}$/),\n validate: z.enum(['strict', 'loose']),\n }),\n paths: z.object({\n booking: path,\n manage: path,\n portal: path.nullable(),\n portalLogin: path,\n api: path,\n portalApi: path,\n avatar: path,\n }),\n query: z.object({\n category: key,\n service: key,\n stylist: key,\n party: key,\n who: key,\n time: key,\n resume: key,\n rebookService: key,\n rebookStylist: key,\n }),\n whoValues: z.object({ child: lowerKey, adult: lowerKey }),\n categories: z\n .array(\n z.object({\n key: lowerKey,\n audience: z.enum(['child', 'adult', 'any']),\n adultEquivalent: z\n .union([\n lowerKey,\n z.array(z.object({ nameIncludes: z.string().min(1), category: lowerKey })),\n ])\n .optional(),\n })\n )\n .min(1),\n fallbackCategory: lowerKey,\n party: z.object({\n maxPeople: z.number().int().min(1).max(20),\n allowParallel: z.boolean(),\n askWhoFirst: z.boolean(),\n maxServicesPerPerson: z.number().int().min(1).max(4),\n }),\n window: z.object({\n rangeDays: z.number().int().min(1).max(62),\n prefetchLimit: z.number().int().min(0).max(20),\n prefetchCategory: lowerKey.nullable(),\n }),\n dayparts: z\n .array(\n z.object({\n key,\n from: z.number().int().min(0).max(23),\n to: z.number().int().min(1).max(24),\n })\n )\n .min(1),\n handoffUrl: z.url({ protocol: /^https?$/ }).nullable(),\n contact: z.object({\n phone: z.string().nullable(),\n address: z.string().nullable(),\n name: z.string(),\n }),\n portal: z.object({\n enabled: z.boolean(),\n methods: z.array(z.enum(['email_code', 'vipps'])),\n cookieName,\n nextCookieName: cookieName,\n vippsBindingCookieName: cookieName.refine(\n (name) => name.startsWith('__Host-'),\n 'must carry the __Host- prefix'\n ),\n vippsLinkCookieName: cookieName,\n returnPaths: z.object({ exact: z.array(path), prefixes: z.array(prefix) }),\n }),\n consent: z.object({\n termsUrl: z.string().min(1).nullable(),\n marketing: z.object({ text: z.string().min(1), version: z.string().min(1) }).nullable(),\n }),\n storageNamespace: key,\n ics: z.object({ prodId: z.string().regex(/^-\\/\\/.+\\/\\/.+$/), uidDomain: z.string().min(1) }),\n monitoring: z.object({ enabled: z.boolean(), sampleRate: z.number().min(0).max(1) }),\n labels: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional(),\n })\n .superRefine((config, ctx) => {\n const keys = config.categories.map((category) => category.key);\n const known = new Set(keys);\n if (known.size !== keys.length) {\n ctx.addIssue({\n code: 'custom',\n path: ['categories'],\n message: 'category keys must be unique',\n });\n }\n if (!known.has(config.fallbackCategory)) {\n ctx.addIssue({\n code: 'custom',\n path: ['fallbackCategory'],\n message: 'must be one of the categories',\n });\n }\n if (config.window.prefetchCategory !== null && !known.has(config.window.prefetchCategory)) {\n ctx.addIssue({\n code: 'custom',\n path: ['window', 'prefetchCategory'],\n message: 'must be one of the categories',\n });\n }\n config.categories.forEach((category, index) => {\n const targets =\n category.adultEquivalent === undefined\n ? []\n : typeof category.adultEquivalent === 'string'\n ? [category.adultEquivalent]\n : category.adultEquivalent.map((rule) => rule.category);\n for (const target of targets) {\n if (!known.has(target)) {\n ctx.addIssue({\n code: 'custom',\n path: ['categories', index, 'adultEquivalent'],\n message: `«${target}» is not one of the categories`,\n });\n }\n }\n });\n // Contiguous and covering the whole day, so every hour has exactly one part.\n let hour = 0;\n config.dayparts.forEach((part, index) => {\n if (part.from !== hour || part.to <= part.from) {\n ctx.addIssue({\n code: 'custom',\n path: ['dayparts', index],\n message: 'dayparts must run contiguously from 0 to 24',\n });\n }\n hour = part.to;\n });\n if (hour !== 24) {\n ctx.addIssue({ code: 'custom', path: ['dayparts'], message: 'dayparts must end at 24' });\n }\n if (config.portal.methods.length === 0 && config.portal.enabled) {\n ctx.addIssue({\n code: 'custom',\n path: ['portal', 'methods'],\n message: 'an enabled portal needs a login method',\n });\n }\n });\n\nfunction deepFreeze<T>(value: T): T {\n if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {\n Object.freeze(value);\n for (const child of Object.values(value)) deepFreeze(child);\n }\n return value;\n}\n\n/** Thrown with zod's issue list when a config does not validate. */\nexport class BookingConfigError extends Error {\n readonly issues: ReadonlyArray<{ path: string; message: string }>;\n\n constructor(issues: ReadonlyArray<{ path: string; message: string }>) {\n super(\n `Invalid booking config:\\n${issues.map((issue) => ` ${issue.path}: ${issue.message}`).join('\\n')}`\n );\n this.name = 'BookingConfigError';\n this.issues = issues;\n }\n}\n\n/**\n * The site's partial config with every default filled, validated and frozen.\n *\n * The portal's three secondary cookie names derive from `cookieName` unless\n * set, and a login may return to the booking page unless `returnPaths` says\n * otherwise. Idempotent: a resolved config resolves to an equal one.\n */\nexport function resolveBookingConfig(input: BookingConfigInput): Readonly<BookingConfig> {\n const merged = merge(defaults(), input) as Omit<BookingConfig, 'portal'> & {\n portal: Partial<BookingConfig['portal']> & Pick<BookingConfig['portal'], 'cookieName'>;\n };\n const session = merged.portal.cookieName;\n const candidate = {\n ...merged,\n portal: {\n ...merged.portal,\n nextCookieName: merged.portal.nextCookieName ?? `${session}_next`,\n vippsBindingCookieName:\n merged.portal.vippsBindingCookieName ?? `__Host-${session}_vipps_bind`,\n vippsLinkCookieName: merged.portal.vippsLinkCookieName ?? `${session}_vipps_link`,\n returnPaths: {\n exact: merged.portal.returnPaths?.exact ?? [merged.paths.booking],\n prefixes: merged.portal.returnPaths?.prefixes ?? [],\n },\n },\n };\n const derived = (Object.keys(DERIVED_COOKIE_NAMES) as DerivedCookieName[]).filter(\n (name) => merged.portal[name] === undefined\n );\n const result = schema.safeParse(candidate);\n if (!result.success) {\n const lengthIssue = derivedCookieNameIssue(session, derived);\n const issues = result.error.issues\n .map((issue) => ({ path: issue.path.join('.'), message: issue.message }))\n // The derived names' own complaints are the base name's, said once.\n .filter(\n (issue) => lengthIssue === null || !derived.some((name) => issue.path === `portal.${name}`)\n );\n throw new BookingConfigError(lengthIssue === null ? issues : [lengthIssue, ...issues]);\n }\n return deepFreeze(result.data as BookingConfig);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"config.mjs","names":[],"sources":["../../src/core/config.ts"],"sourcesContent":["/**\n * What a site tells the booking package about itself.\n *\n * `BookingConfig` is CLIENT-SAFE by construction: it is serialised into the\n * page as a prop, so it never holds a key, a session secret or a cache handle.\n * Those live in `BookingServerOptions`, which only `/next` knows (P4).\n *\n * The package reads no environment variable and no CMS. A site resolves its\n * own switches (a handoff URL, whether accounts are on, the contact block) and\n * passes the answers in.\n *\n * Every field but `timeZone` has a default. `resolveBookingConfig` fills them,\n * validates the whole object with zod and freezes it, so a config that reached\n * a factory is one that passed.\n */\n\nimport { z } from 'zod';\nimport type { BookingLabels } from './labels';\n\n/** A service group, in display order. Medal's free-string `category` maps onto `key`. */\nexport interface BookingCategory {\n key: string;\n /** Who the group is for. `child` is the kids' path: guest children, `?antall=`, the held link service. */\n audience: 'child' | 'adult' | 'any';\n /**\n * For a child's group: which adult group a service «grows up» into once the\n * child has outgrown the kids' menu. A plain key maps every service in the\n * group; a list maps by a word in the service name, first match wins\n * (`{ nameIncludes: 'gutt', category: 'herre' }`). Case-insensitive.\n */\n adultEquivalent?: string | ReadonlyArray<{ nameIncludes: string; category: string }>;\n}\n\n/** A named part of the day on the business's clock, in local hours `[from, to)`. */\nexport interface BookingDaypart {\n key: string;\n from: number;\n to: number;\n}\n\nexport interface BookingConfig {\n /** IANA zone the business keeps its hours in. Required. */\n timeZone: string;\n /** BCP-47, for every `Intl` formatter and the label pack. */\n locale: string;\n /** ISO 4217. Amounts stay in minor units (øre, pence) on the wire. */\n currency: string;\n /** `NO` is the only strict rule set in 0.x; every other country is `loose`. */\n phone: { country: string; validate: 'strict' | 'loose' };\n /** URLs this site serves. */\n paths: {\n booking: string;\n /** Prefix of the manage page; the token is the next segment. */\n manage: string;\n /** `null` = no customer portal. */\n portal: string | null;\n portalLogin: string;\n api: string;\n portalApi: string;\n avatar: string;\n };\n /**\n * The query keys a link may carry into the booking page.\n *\n * `category`, `service`, `stylist`, `party` and `who` are the marketing\n * deep-link keys; `rebookService` / `rebookStylist` are the portal's «book\n * again» link, read by the wizard's own prefill; `resume` marks a return\n * from an external login; `time` is reserved for a pre-picked start.\n */\n query: {\n category: string;\n service: string;\n stylist: string;\n party: string;\n who: string;\n time: string;\n resume: string;\n rebookService: string;\n rebookStylist: string;\n };\n /** The values `query.who` accepts. */\n whoValues: { child: string; adult: string };\n /** Service groups, in display order. */\n categories: BookingCategory[];\n /** Where an unknown Medal category lands. Must be one of `categories`. */\n fallbackCategory: string;\n /** Step 1's rules. */\n party: {\n maxPeople: number;\n allowParallel: boolean;\n askWhoFirst: boolean;\n /**\n * How many services one person may book as a single visit (1–4; Medal's\n * cap is 4). `1` — the default — is the one-tap service step: a tap\n * answers it. Above `1`, `<BookingWizard>` draws the multi-select step:\n * each person ticks up to this many, done back to back as one visit.\n */\n maxServicesPerPerson: number;\n };\n /** The bookable window and the page's prefetch. */\n window: { rangeDays: number; prefetchLimit: number; prefetchCategory: string | null };\n /** Named day parts for the time step, contiguous from 0 to 24. */\n dayparts: BookingDaypart[];\n /** Set = the booking path hands off (307) to this URL instead of rendering. */\n handoffUrl: string | null;\n /** The business's own contact, for «call us» and the unavailable screen. */\n contact: { phone: string | null; address: string | null; name: string };\n portal: {\n enabled: boolean;\n methods: Array<'email_code' | 'vipps'>;\n /** The session cookie. */\n cookieName: string;\n /** Where a login that left the site (Vipps) returns to; read once. */\n nextCookieName: string;\n /** Must be `__Host-`-prefixed: Secure, `Path=/`, no `Domain`. */\n vippsBindingCookieName: string;\n vippsLinkCookieName: string;\n /**\n * Where a finished login may send the visitor. `exact` matches the\n * pathname only (the booking page, so a deeper manage URL never\n * qualifies); `prefixes` matches anything below.\n */\n returnPaths: { exact: string[]; prefixes: string[] };\n };\n /**\n * Booking only for a logged-in parent: the details step becomes the login\n * until there is a session, and `/create` refuses without one.\n */\n account: { required: boolean };\n /**\n * Opt-in screen features, all off by default so a site's wizard only\n * changes when it asks:\n * - `recap` — the details step opens with what is being booked (day, hours,\n * who, stylist, total, «edit», and a swap to another stylist free at the\n * same minute when «first available» picked one).\n * - `soonest` — the time step leads with the earliest free starts.\n * - `dayFullness` — each day chip shows how much room the day has.\n * - `summaryDetail` — the sticky bar's time and price on a second line.\n * - `hideDisabledNext` — the bar's «next» only while it does something.\n * - `firstAvailableFaces` — «first available» drawn as the qualified\n * stylists' faces.\n * - `stylistEdgeFade` — the phone stylist row fades at its edge.\n * - `guestParty` — a guest picks a number of children and «me too» instead\n * of the chips, so a parent and child book together without an account.\n * - `childMenuFirst` — a child's service list leads with the children's and\n * anyone's groups; the grown-ups' groups move below the «usually not for\n * this age» divider, still one tap away.\n */\n screens: {\n recap: boolean;\n soonest: boolean;\n dayFullness: boolean;\n summaryDetail: boolean;\n hideDisabledNext: boolean;\n firstAvailableFaces: boolean;\n stylistEdgeFade: boolean;\n guestParty: boolean;\n childMenuFirst: boolean;\n };\n consent: {\n termsUrl: string | null;\n /** `null` = no marketing box. */\n marketing: { text: string; version: string } | null;\n };\n /** Namespaces browser storage keys: `<ns>:booking:draft`, … */\n storageNamespace: string;\n /**\n * `prodId` stamps every calendar file. `uidDomain` is for UIDs the package\n * mints itself (`/react`, 0.1.0); a booking id passed in as `uid` is used as\n * is, so a calendar entry made before a site moved onto the package is still\n * the same entry after it.\n */\n ics: { prodId: string; uidDomain: string };\n monitoring: { enabled: boolean; sampleRate: number };\n /** Copy, merged over the built-in pack for `locale`. */\n labels?: Partial<BookingLabels>;\n}\n\ntype DeepPartial<T> = T extends readonly unknown[]\n ? T\n : T extends object\n ? { [K in keyof T]?: DeepPartial<T[K]> }\n : T;\n\n/** What a site passes: `timeZone`, and whatever it wants to change. */\nexport type BookingConfigInput = { timeZone: string } & DeepPartial<\n Omit<BookingConfig, 'timeZone'>\n>;\n\nconst DEFAULT_PATHS: BookingConfig['paths'] = {\n booking: '/bestill',\n manage: '/bestill/administrer',\n portal: '/min-side',\n portalLogin: '/min-side/logg-inn',\n api: '/api/booking',\n portalApi: '/api/portal',\n avatar: '/api/booking/avatar',\n};\n\n/** Everything but `timeZone` (required) and the portal cookie names (derived). */\nfunction defaults(): Omit<BookingConfig, 'timeZone' | 'portal'> & {\n portal: Omit<\n BookingConfig['portal'],\n 'nextCookieName' | 'vippsBindingCookieName' | 'vippsLinkCookieName' | 'returnPaths'\n >;\n} {\n return {\n locale: 'nb-NO',\n currency: 'NOK',\n phone: { country: 'NO', validate: 'strict' },\n paths: { ...DEFAULT_PATHS },\n query: {\n category: 'kategori',\n service: 'tjeneste',\n stylist: 'frisor',\n party: 'antall',\n who: 'hvem',\n time: 'tid',\n resume: 'resume',\n rebookService: 'service',\n rebookStylist: 'stylist',\n },\n whoValues: { child: 'barn', adult: 'voksen' },\n categories: [\n { key: 'barn', audience: 'child' },\n { key: 'annet', audience: 'any' },\n ],\n fallbackCategory: 'annet',\n party: { maxPeople: 3, allowParallel: true, askWhoFirst: true, maxServicesPerPerson: 1 },\n window: { rangeDays: 7, prefetchLimit: 4, prefetchCategory: null },\n dayparts: [\n { key: 'formiddag', from: 0, to: 12 },\n { key: 'ettermiddag', from: 12, to: 17 },\n { key: 'kveld', from: 17, to: 24 },\n ],\n handoffUrl: null,\n contact: { phone: null, address: null, name: '' },\n portal: { enabled: false, methods: ['email_code'], cookieName: 'booking_portal' },\n account: { required: false },\n screens: {\n recap: false,\n soonest: false,\n dayFullness: false,\n summaryDetail: false,\n hideDisabledNext: false,\n firstAvailableFaces: false,\n stylistEdgeFade: false,\n guestParty: false,\n childMenuFirst: false,\n },\n consent: { termsUrl: null, marketing: null },\n storageNamespace: 'medal',\n ics: { prodId: '-//Medal Social//Booking//EN', uidDomain: 'booking.invalid' },\n monitoring: { enabled: true, sampleRate: 1 },\n };\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/**\n * Objects merge key by key; arrays, scalars and `null` replace. Only\n * `undefined` keeps the default — `paths.portal: null` means «no portal».\n */\nfunction merge(base: unknown, patch: unknown): unknown {\n if (patch === undefined) return base;\n if (!isRecord(base) || !isRecord(patch)) return patch;\n const out: Record<string, unknown> = { ...base };\n for (const [key, value] of Object.entries(patch)) out[key] = merge(base[key], value);\n return out;\n}\n\nfunction validTimeZone(value: string): boolean {\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: value });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction validLocale(value: string): boolean {\n try {\n return Intl.getCanonicalLocales(value).length === 1;\n } catch {\n return false;\n }\n}\n\nconst path = z.string().regex(/^\\/[^\\s?#]*$/, 'must be a root-relative path');\nconst key = z.string().regex(/^[\\w-]{1,64}$/, 'must be 1–64 word characters');\n/** Group keys and `?hvem=` values are matched against a lower-cased query value. */\nconst lowerKey = z.string().regex(/^[a-z0-9_-]{1,64}$/, 'must be 1–64 lower-case word characters');\n/** A prefix must end at a segment boundary, or `/flow` would also admit `/flow-evil`. */\nconst prefix = path.refine((value) => value.endsWith('/'), 'a prefix must end with /');\n/** The longest cookie name the config takes. */\nconst MAX_COOKIE_NAME = 128;\n/** RFC 6265 token characters, which is every name a browser keeps intact. */\nconst cookieName = z.string().regex(/^[!#$%&'*+\\-.^_`|~0-9A-Za-z]{1,128}$/);\n\n/**\n * How each secondary portal cookie name derives from `portal.cookieName` when\n * the site sets none — the prefix and suffix, so a too-long base can be\n * reported against the name the site actually wrote.\n */\nconst DERIVED_COOKIE_NAMES = {\n nextCookieName: { prefix: '', suffix: '_next' },\n vippsBindingCookieName: { prefix: '__Host-', suffix: '_vipps_bind' },\n vippsLinkCookieName: { prefix: '', suffix: '_vipps_link' },\n} as const;\n\ntype DerivedCookieName = keyof typeof DERIVED_COOKIE_NAMES;\n\n/**\n * A derived name that would be too long, said about `portal.cookieName`\n * (which the site wrote) rather than a name it never set. `null` when every\n * derived name fits — the schema then judges the characters as usual — and\n * when the base is not a string at all (a runtime input the types did not\n * catch): the schema's own issue on `portal.cookieName` says so.\n */\nfunction derivedCookieNameIssue(\n session: unknown,\n derived: readonly DerivedCookieName[]\n): { path: string; message: string } | null {\n if (typeof session !== 'string') return null;\n const tooLong = derived.filter((name) => {\n const { prefix, suffix } = DERIVED_COOKIE_NAMES[name];\n return prefix.length + session.length + suffix.length > MAX_COOKIE_NAME;\n });\n if (tooLong.length === 0) return null;\n const room = Math.min(\n ...tooLong.map((name) => {\n const { prefix, suffix } = DERIVED_COOKIE_NAMES[name];\n return MAX_COOKIE_NAME - prefix.length - suffix.length;\n })\n );\n return {\n path: 'portal.cookieName',\n message: `must be at most ${room} characters, or the derived ${tooLong\n .map((name) => `portal.${name}`)\n .join(', ')} would exceed ${MAX_COOKIE_NAME}; shorten it or set ${\n tooLong.length === 1 ? 'that name' : 'those names'\n } explicitly`,\n };\n}\n\nconst schema = z\n .object({\n timeZone: z.string().refine(validTimeZone, 'must be an IANA time zone'),\n locale: z.string().refine(validLocale, 'must be a BCP-47 locale'),\n currency: z.string().regex(/^[A-Z]{3}$/, 'must be an ISO 4217 code'),\n phone: z.object({\n country: z.string().regex(/^[A-Z]{2}$/),\n validate: z.enum(['strict', 'loose']),\n }),\n paths: z.object({\n booking: path,\n manage: path,\n portal: path.nullable(),\n portalLogin: path,\n api: path,\n portalApi: path,\n avatar: path,\n }),\n query: z.object({\n category: key,\n service: key,\n stylist: key,\n party: key,\n who: key,\n time: key,\n resume: key,\n rebookService: key,\n rebookStylist: key,\n }),\n whoValues: z.object({ child: lowerKey, adult: lowerKey }),\n categories: z\n .array(\n z.object({\n key: lowerKey,\n audience: z.enum(['child', 'adult', 'any']),\n adultEquivalent: z\n .union([\n lowerKey,\n z.array(z.object({ nameIncludes: z.string().min(1), category: lowerKey })),\n ])\n .optional(),\n })\n )\n .min(1),\n fallbackCategory: lowerKey,\n party: z.object({\n maxPeople: z.number().int().min(1).max(20),\n allowParallel: z.boolean(),\n askWhoFirst: z.boolean(),\n maxServicesPerPerson: z.number().int().min(1).max(4),\n }),\n window: z.object({\n rangeDays: z.number().int().min(1).max(62),\n prefetchLimit: z.number().int().min(0).max(20),\n prefetchCategory: lowerKey.nullable(),\n }),\n dayparts: z\n .array(\n z.object({\n key,\n from: z.number().int().min(0).max(23),\n to: z.number().int().min(1).max(24),\n })\n )\n .min(1),\n handoffUrl: z.url({ protocol: /^https?$/ }).nullable(),\n contact: z.object({\n phone: z.string().nullable(),\n address: z.string().nullable(),\n name: z.string(),\n }),\n portal: z.object({\n enabled: z.boolean(),\n methods: z.array(z.enum(['email_code', 'vipps'])),\n cookieName,\n nextCookieName: cookieName,\n vippsBindingCookieName: cookieName.refine(\n (name) => name.startsWith('__Host-'),\n 'must carry the __Host- prefix'\n ),\n vippsLinkCookieName: cookieName,\n returnPaths: z.object({ exact: z.array(path), prefixes: z.array(prefix) }),\n }),\n account: z.object({ required: z.boolean() }),\n screens: z.object({\n recap: z.boolean(),\n soonest: z.boolean(),\n dayFullness: z.boolean(),\n summaryDetail: z.boolean(),\n hideDisabledNext: z.boolean(),\n firstAvailableFaces: z.boolean(),\n stylistEdgeFade: z.boolean(),\n guestParty: z.boolean(),\n childMenuFirst: z.boolean(),\n }),\n consent: z.object({\n termsUrl: z.string().min(1).nullable(),\n marketing: z.object({ text: z.string().min(1), version: z.string().min(1) }).nullable(),\n }),\n storageNamespace: key,\n ics: z.object({ prodId: z.string().regex(/^-\\/\\/.+\\/\\/.+$/), uidDomain: z.string().min(1) }),\n monitoring: z.object({ enabled: z.boolean(), sampleRate: z.number().min(0).max(1) }),\n labels: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional(),\n })\n .superRefine((config, ctx) => {\n const keys = config.categories.map((category) => category.key);\n const known = new Set(keys);\n if (known.size !== keys.length) {\n ctx.addIssue({\n code: 'custom',\n path: ['categories'],\n message: 'category keys must be unique',\n });\n }\n if (!known.has(config.fallbackCategory)) {\n ctx.addIssue({\n code: 'custom',\n path: ['fallbackCategory'],\n message: 'must be one of the categories',\n });\n }\n if (config.window.prefetchCategory !== null && !known.has(config.window.prefetchCategory)) {\n ctx.addIssue({\n code: 'custom',\n path: ['window', 'prefetchCategory'],\n message: 'must be one of the categories',\n });\n }\n config.categories.forEach((category, index) => {\n const targets =\n category.adultEquivalent === undefined\n ? []\n : typeof category.adultEquivalent === 'string'\n ? [category.adultEquivalent]\n : category.adultEquivalent.map((rule) => rule.category);\n for (const target of targets) {\n if (!known.has(target)) {\n ctx.addIssue({\n code: 'custom',\n path: ['categories', index, 'adultEquivalent'],\n message: `«${target}» is not one of the categories`,\n });\n }\n }\n });\n // Contiguous and covering the whole day, so every hour has exactly one part.\n let hour = 0;\n config.dayparts.forEach((part, index) => {\n if (part.from !== hour || part.to <= part.from) {\n ctx.addIssue({\n code: 'custom',\n path: ['dayparts', index],\n message: 'dayparts must run contiguously from 0 to 24',\n });\n }\n hour = part.to;\n });\n if (hour !== 24) {\n ctx.addIssue({ code: 'custom', path: ['dayparts'], message: 'dayparts must end at 24' });\n }\n if (config.portal.methods.length === 0 && config.portal.enabled) {\n ctx.addIssue({\n code: 'custom',\n path: ['portal', 'methods'],\n message: 'an enabled portal needs a login method',\n });\n }\n if (config.account.required && !config.portal.enabled) {\n ctx.addIssue({\n code: 'custom',\n path: ['account', 'required'],\n message: 'account.required needs portal.enabled',\n });\n }\n });\n\nfunction deepFreeze<T>(value: T): T {\n if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {\n Object.freeze(value);\n for (const child of Object.values(value)) deepFreeze(child);\n }\n return value;\n}\n\n/** Thrown with zod's issue list when a config does not validate. */\nexport class BookingConfigError extends Error {\n readonly issues: ReadonlyArray<{ path: string; message: string }>;\n\n constructor(issues: ReadonlyArray<{ path: string; message: string }>) {\n super(\n `Invalid booking config:\\n${issues.map((issue) => ` ${issue.path}: ${issue.message}`).join('\\n')}`\n );\n this.name = 'BookingConfigError';\n this.issues = issues;\n }\n}\n\n/**\n * The site's partial config with every default filled, validated and frozen.\n *\n * The portal's three secondary cookie names derive from `cookieName` unless\n * set, and a login may return to the booking page unless `returnPaths` says\n * otherwise. Idempotent: a resolved config resolves to an equal one.\n */\nexport function resolveBookingConfig(input: BookingConfigInput): Readonly<BookingConfig> {\n const merged = merge(defaults(), input) as Omit<BookingConfig, 'portal'> & {\n portal: Partial<BookingConfig['portal']> & Pick<BookingConfig['portal'], 'cookieName'>;\n };\n const session = merged.portal.cookieName;\n const candidate = {\n ...merged,\n portal: {\n ...merged.portal,\n nextCookieName: merged.portal.nextCookieName ?? `${session}_next`,\n vippsBindingCookieName:\n merged.portal.vippsBindingCookieName ?? `__Host-${session}_vipps_bind`,\n vippsLinkCookieName: merged.portal.vippsLinkCookieName ?? `${session}_vipps_link`,\n returnPaths: {\n exact: merged.portal.returnPaths?.exact ?? [merged.paths.booking],\n prefixes: merged.portal.returnPaths?.prefixes ?? [],\n },\n },\n };\n const derived = (Object.keys(DERIVED_COOKIE_NAMES) as DerivedCookieName[]).filter(\n (name) => merged.portal[name] === undefined\n );\n const result = schema.safeParse(candidate);\n if (!result.success) {\n const lengthIssue = derivedCookieNameIssue(session, derived);\n const issues = result.error.issues\n .map((issue) => ({ path: issue.path.join('.'), message: issue.message }))\n // The derived names' own complaints are the base name's, said once.\n .filter(\n (issue) => lengthIssue === null || !derived.some((name) => issue.path === `portal.${name}`)\n );\n throw new BookingConfigError(lengthIssue === null ? issues : [lengthIssue, ...issues]);\n }\n return deepFreeze(result.data as BookingConfig);\n}\n"],"mappings":"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"}
|
package/dist/core/machine.d.mts
CHANGED
|
@@ -294,10 +294,27 @@ export type WizardAction =
|
|
|
294
294
|
* see `isGuestSeat` — and a guest «Voksen» becomes «Meg selv». Only while
|
|
295
295
|
* nothing is held on them (steps 1 and 2); from the hour on they are real
|
|
296
296
|
* chairs with a time, and «Bekreft» names them. A party emptied by it goes
|
|
297
|
-
* back to step 1, where the children are.
|
|
297
|
+
* back to step 1, where the children are. A guest chair in `keep` stays:
|
|
298
|
+
* one an account switch made of a seat (`unseatPeople`), whose services are
|
|
299
|
+
* the visit the parent is booking, not a guest's count chip.
|
|
298
300
|
*/
|
|
299
301
|
{
|
|
300
302
|
type: 'seatFamily';
|
|
303
|
+
keep?: readonly string[];
|
|
304
|
+
} |
|
|
305
|
+
/**
|
|
306
|
+
* Somebody else has logged in over this party — another account than the
|
|
307
|
+
* one it was seated under: each seat in `keys` (the previous parent's saved
|
|
308
|
+
* children) becomes a guest chair. Its services stay, and so does the hour —
|
|
309
|
+
* the basket is the same visit, only WHO changed — but its name, year and
|
|
310
|
+
* person id go with the parent they belonged to, and «Bekreft» asks again.
|
|
311
|
+
* With `into`, the seat `key` becomes `into[key]` instead — the child of the
|
|
312
|
+
* parent who logged in, found by id — unless that child is seated already.
|
|
313
|
+
*/
|
|
314
|
+
{
|
|
315
|
+
type: 'unseatPeople';
|
|
316
|
+
keys: readonly string[];
|
|
317
|
+
into?: Readonly<Record<string, WizardPerson>>;
|
|
301
318
|
} |
|
|
302
319
|
/**
|
|
303
320
|
* One person joins the party as it is WHEN this lands — a child the parent
|
|
@@ -636,6 +653,15 @@ export interface Wizard {
|
|
|
636
653
|
showsPartyMode: typeof showsPartyMode;
|
|
637
654
|
itemResourceIds: typeof itemResourceIds;
|
|
638
655
|
summaryLine(state: WizardState, resolveStylistName: (resourceId: string) => string | null, now?: number): string;
|
|
656
|
+
/**
|
|
657
|
+
* `summaryLine`'s words in two parts, for a bar with a second line: `line`
|
|
658
|
+
* is what (and with whom), `detail` when and for how much. The same parts
|
|
659
|
+
* as the one-line form, so the two can never disagree.
|
|
660
|
+
*/
|
|
661
|
+
summaryParts(state: WizardState, resolveStylistName: (resourceId: string) => string | null, now?: number): {
|
|
662
|
+
line: string;
|
|
663
|
+
detail: string;
|
|
664
|
+
};
|
|
639
665
|
guestChild: typeof guestChild;
|
|
640
666
|
isGuestSeat: typeof isGuestSeat;
|
|
641
667
|
}
|
package/dist/core/machine.mjs
CHANGED
|
@@ -289,9 +289,10 @@ function withPersonFields(person, item) {
|
|
|
289
289
|
};
|
|
290
290
|
}
|
|
291
291
|
/** `seatFamily` — see the action. */
|
|
292
|
-
function seatFamily(state) {
|
|
292
|
+
function seatFamily(state, keep = []) {
|
|
293
|
+
const drops = (person) => isGuestSeat(person) && !keep.includes(person.key);
|
|
293
294
|
if (state.step !== "who" && state.step !== "service") return state;
|
|
294
|
-
if (!state.people.some(
|
|
295
|
+
if (!state.people.some(drops)) return state;
|
|
295
296
|
const people = [];
|
|
296
297
|
const choices = [];
|
|
297
298
|
const extras = [];
|
|
@@ -300,7 +301,7 @@ function seatFamily(state) {
|
|
|
300
301
|
key: SELF_KEY,
|
|
301
302
|
adult: true
|
|
302
303
|
} : person;
|
|
303
|
-
if (
|
|
304
|
+
if (drops(seat) || people.some((other) => other.key === seat.key)) return;
|
|
304
305
|
people.push(seat);
|
|
305
306
|
choices.push(state.choices[index] ?? null);
|
|
306
307
|
extras.push(extrasAt(state, index));
|
|
@@ -311,6 +312,32 @@ function seatFamily(state) {
|
|
|
311
312
|
error: null
|
|
312
313
|
};
|
|
313
314
|
}
|
|
315
|
+
/** `unseatPeople` — see the action. */
|
|
316
|
+
function unseatPeople(state, keys, into = {}) {
|
|
317
|
+
const leaving = state.people.map((person) => keys.includes(person.key));
|
|
318
|
+
if (!leaving.includes(true)) return state;
|
|
319
|
+
const taken = new Set(state.people.map((person) => person.key));
|
|
320
|
+
let n = 0;
|
|
321
|
+
const people = state.people.map((person, index) => {
|
|
322
|
+
if (!leaving[index]) return person;
|
|
323
|
+
const own = into[person.key];
|
|
324
|
+
if (own !== void 0 && !taken.has(own.key)) {
|
|
325
|
+
taken.add(own.key);
|
|
326
|
+
return own;
|
|
327
|
+
}
|
|
328
|
+
let seat = guestChild(++n);
|
|
329
|
+
while (taken.has(seat.key)) seat = guestChild(++n);
|
|
330
|
+
taken.add(seat.key);
|
|
331
|
+
return seat;
|
|
332
|
+
});
|
|
333
|
+
const items = state.items.map((item, index) => leaving[index] ? itemFor(people[index], item.service, item.extraServices ?? []) : item);
|
|
334
|
+
return {
|
|
335
|
+
...state,
|
|
336
|
+
people,
|
|
337
|
+
items,
|
|
338
|
+
error: null
|
|
339
|
+
};
|
|
340
|
+
}
|
|
314
341
|
/** `pickServiceFor` — see the action. */
|
|
315
342
|
function pickServiceFor(state, action) {
|
|
316
343
|
if (!state.people[action.index] && !(action.index === 0 && state.people.length === 0)) return state;
|
|
@@ -605,7 +632,8 @@ function createWizard(config) {
|
|
|
605
632
|
case "choosePeople": return choosePeople(state, action);
|
|
606
633
|
case "pickServiceFor": return pickServiceFor(state, action);
|
|
607
634
|
case "toggleServiceFor": return toggleServiceFor(state, action);
|
|
608
|
-
case "seatFamily": return seatFamily(state);
|
|
635
|
+
case "seatFamily": return seatFamily(state, action.keep);
|
|
636
|
+
case "unseatPeople": return unseatPeople(state, action.keys, action.into);
|
|
609
637
|
case "addPerson": return addPerson(state, action.person);
|
|
610
638
|
case "holdService": return {
|
|
611
639
|
...state,
|
|
@@ -961,6 +989,25 @@ function createWizard(config) {
|
|
|
961
989
|
priceLabel(state) ?? UNCHOSEN
|
|
962
990
|
].join(SEPARATOR);
|
|
963
991
|
}
|
|
992
|
+
function summaryParts(state, resolveStylistName, now = Date.now()) {
|
|
993
|
+
if (state.items.length === 0) return {
|
|
994
|
+
line: state.people.length > 0 ? peopleLabel(state.people) : "",
|
|
995
|
+
detail: ""
|
|
996
|
+
};
|
|
997
|
+
const service = serviceLabel(state);
|
|
998
|
+
const price = money.formatMinor(totalPriceOre(state.items, state.startTs));
|
|
999
|
+
if (showsPartyMode(state)) return {
|
|
1000
|
+
line: service,
|
|
1001
|
+
detail: price
|
|
1002
|
+
};
|
|
1003
|
+
const unnamedPreference = (state.resolvedResourceId ?? state.resourceId) === null;
|
|
1004
|
+
const stylist = stylistLabel(state, resolveStylistName) ?? (unnamedPreference ? labelText(labels["summary.firstAvailable"]) : UNCHOSEN);
|
|
1005
|
+
const when = timeLabel(state.startTs, now) ?? labelText(labels["summary.pickTime"]);
|
|
1006
|
+
return {
|
|
1007
|
+
line: [service, stylist].join(SEPARATOR),
|
|
1008
|
+
detail: [when, price].join(SEPARATOR)
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
964
1011
|
return {
|
|
965
1012
|
maxPeople,
|
|
966
1013
|
initialState,
|
|
@@ -978,6 +1025,7 @@ function createWizard(config) {
|
|
|
978
1025
|
showsPartyMode,
|
|
979
1026
|
itemResourceIds,
|
|
980
1027
|
summaryLine,
|
|
1028
|
+
summaryParts,
|
|
981
1029
|
guestChild,
|
|
982
1030
|
isGuestSeat
|
|
983
1031
|
};
|