@medalsocial/booking 0.3.1 → 0.4.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 +10 -0
- package/README.md +2 -1
- package/dist/core/config.d.mts +14 -0
- package/dist/core/config.mjs +8 -4
- package/dist/core/config.mjs.map +1 -1
- package/dist/core/draft-store.d.mts +5 -0
- package/dist/core/draft-store.mjs +1 -0
- package/dist/core/draft-store.mjs.map +1 -1
- package/dist/core/machine.d.mts +6 -0
- package/dist/core/machine.mjs +22 -3
- package/dist/core/machine.mjs.map +1 -1
- package/dist/react/BookingWizard.mjs +2 -0
- package/dist/react/BookingWizard.mjs.map +1 -1
- package/dist/react/labels/screens.mjs +4 -0
- package/dist/react/labels/screens.mjs.map +1 -1
- package/dist/react/useBooking.mjs +4 -2
- package/dist/react/useBooking.mjs.map +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @medalsocial/booking
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#212](https://github.com/Medal-Social/MedalSocial-SDK/pull/212) [`7504690`](https://github.com/Medal-Social/MedalSocial-SDK/commit/75046906f272e3e0c74d97801cc06d9825020c74) Thanks [@adaadev](https://github.com/adaadev)! - Config `screens.wideLayouts` (default `false`): the stylist step keeps its row of faces at every width, with the chosen stylist's bio under it, and the time step uses the adaptive layout. On an iPad the days sit beside the times; on a desktop the whole week is a grid of times. Built-in nb/en words for `time.week.none` and `time.week.more`.
|
|
8
|
+
|
|
9
|
+
Requires `@medalsocial/meda` ^3.8.0.
|
|
10
|
+
|
|
11
|
+
- [#212](https://github.com/Medal-Social/MedalSocial-SDK/pull/212) [`7504690`](https://github.com/Medal-Social/MedalSocial-SDK/commit/75046906f272e3e0c74d97801cc06d9825020c74) Thanks [@adaadev](https://github.com/adaadev)! - Config `party.adultTogether` (default `false`): a party with a grown-up in it starts «Samtidig», side by side, and goes back to «Rett etter hverandre» when the grown-up leaves it. It stops deciding once the parent picks a mode or a party slot, and a party with a named stylist stays one after another, including a stylist a link names after the party is seated («Første ledige» gives «Samtidig» back). A restored draft keeps a mode the parent picked; one the default picked stays the default's. New optional `WizardState.partyModeChosen` and `WizardDraft.partyModeChosen`.
|
|
12
|
+
|
|
3
13
|
## 0.3.1
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ its own values in.
|
|
|
73
73
|
| `paths` | `/bestill`, `/bestill/administrer`, `/min-side`, `/api/booking`, … | The URLs the site serves |
|
|
74
74
|
| `query` / `whoValues` | `kategori`, `tjeneste`, `frisor`, `antall`, `hvem`, … | Deep-link query keys and values |
|
|
75
75
|
| `categories` / `fallbackCategory` | `barn` (child), `annet` (any) | Service groups in display order; unknown Medal categories land on the fallback |
|
|
76
|
-
| `party` | `{ maxPeople: 3, allowParallel: true, askWhoFirst: true, maxServicesPerPerson: 1 }` | Step 1's rules; `maxServicesPerPerson` (1–4) caps one person's visit: `1` is the one-tap service step, above `1` the wizard's service step is multi-select (tick up to that many, then «Neste») |
|
|
76
|
+
| `party` | `{ maxPeople: 3, allowParallel: true, askWhoFirst: true, maxServicesPerPerson: 1, adultTogether: false }` | Step 1's rules; `maxServicesPerPerson` (1–4) caps one person's visit: `1` is the one-tap service step, above `1` the wizard's service step is multi-select (tick up to that many, then «Neste»). `adultTogether` starts a party with a grown-up in it «Samtidig» (side by side) until the parent picks a mode or names one stylist; needs `allowParallel` |
|
|
77
77
|
| `window` | `{ rangeDays: 7, prefetchLimit: 4, prefetchCategory: null }` | Bookable window and page prefetch |
|
|
78
78
|
| `dayparts` | `formiddag` 0–12, `ettermiddag` 12–17, `kveld` 17–24 | Named parts of the day, contiguous from 0 to 24 |
|
|
79
79
|
| `portal` | disabled; cookie `booking_portal` (+ derived `_next`, `__Host-…_vipps_bind`, `_vipps_link`) | Customer portal switches and cookie names |
|
|
@@ -99,6 +99,7 @@ built-in packs carry their words; override them with `labels`.
|
|
|
99
99
|
| `stylistEdgeFade` | The phone's sideways stylist row fades at its edge |
|
|
100
100
|
| `guestParty` | A guest picks a number of children and «me too» instead of the chips, so a parent and child book together without an account; the step opens with one child seated (also in the server render) (`who.party.*`) |
|
|
101
101
|
| `childMenuFirst` | A child's service list leads with the children's and anyone's groups; grown-ups' groups move below the «usually not for this age» divider |
|
|
102
|
+
| `wideLayouts` | The stylist step keeps its row of faces at every width, with the chosen stylist's bio under it; the time step puts the days beside the times on an iPad and shows the week as a grid of times on a desktop (`time.week.*` labels) |
|
|
102
103
|
|
|
103
104
|
A site moving onto the package keeps its live sessions, drafts and cache
|
|
104
105
|
entries by passing the paths, cookie names and storage namespace it already
|
package/dist/core/config.d.mts
CHANGED
|
@@ -86,6 +86,15 @@ export interface BookingConfig {
|
|
|
86
86
|
* each person ticks up to this many, done back to back as one visit.
|
|
87
87
|
*/
|
|
88
88
|
maxServicesPerPerson: number;
|
|
89
|
+
/**
|
|
90
|
+
* A party with a grown-up in it starts «Samtidig» (`partyMode:
|
|
91
|
+
* 'parallel'`): the child in one chair, the parent in the next, done
|
|
92
|
+
* together. Only until the parent picks a mode, or a stylist link names
|
|
93
|
+
* one stylist (one stylist cannot cut two people at once). Needs
|
|
94
|
+
* `allowParallel`. Off by default: everyone else keeps «Rett etter
|
|
95
|
+
* hverandre».
|
|
96
|
+
*/
|
|
97
|
+
adultTogether: boolean;
|
|
89
98
|
};
|
|
90
99
|
/** The bookable window and the page's prefetch. */
|
|
91
100
|
window: {
|
|
@@ -148,6 +157,10 @@ export interface BookingConfig {
|
|
|
148
157
|
* - `childMenuFirst` — a child's service list leads with the children's and
|
|
149
158
|
* anyone's groups; the grown-ups' groups move below the «usually not for
|
|
150
159
|
* this age» divider, still one tap away.
|
|
160
|
+
* - `wideLayouts` — the stylist step keeps its row of faces at every width
|
|
161
|
+
* (the chosen stylist's bio under it), and the time step goes
|
|
162
|
+
* `layout="adaptive"`: days beside the times on an iPad, the whole week as
|
|
163
|
+
* a grid of times on a desktop. Needs the `time.week.*` labels.
|
|
151
164
|
*/
|
|
152
165
|
screens: {
|
|
153
166
|
recap: boolean;
|
|
@@ -159,6 +172,7 @@ export interface BookingConfig {
|
|
|
159
172
|
stylistEdgeFade: boolean;
|
|
160
173
|
guestParty: boolean;
|
|
161
174
|
childMenuFirst: boolean;
|
|
175
|
+
wideLayouts: boolean;
|
|
162
176
|
};
|
|
163
177
|
consent: {
|
|
164
178
|
termsUrl: string | null;
|
package/dist/core/config.mjs
CHANGED
|
@@ -61,7 +61,8 @@ function defaults() {
|
|
|
61
61
|
maxPeople: 3,
|
|
62
62
|
allowParallel: true,
|
|
63
63
|
askWhoFirst: true,
|
|
64
|
-
maxServicesPerPerson: 1
|
|
64
|
+
maxServicesPerPerson: 1,
|
|
65
|
+
adultTogether: false
|
|
65
66
|
},
|
|
66
67
|
window: {
|
|
67
68
|
rangeDays: 7,
|
|
@@ -106,7 +107,8 @@ function defaults() {
|
|
|
106
107
|
firstAvailableFaces: false,
|
|
107
108
|
stylistEdgeFade: false,
|
|
108
109
|
guestParty: false,
|
|
109
|
-
childMenuFirst: false
|
|
110
|
+
childMenuFirst: false,
|
|
111
|
+
wideLayouts: false
|
|
110
112
|
},
|
|
111
113
|
consent: {
|
|
112
114
|
termsUrl: null,
|
|
@@ -252,7 +254,8 @@ const schema = z.object({
|
|
|
252
254
|
maxPeople: z.number().int().min(1).max(20),
|
|
253
255
|
allowParallel: z.boolean(),
|
|
254
256
|
askWhoFirst: z.boolean(),
|
|
255
|
-
maxServicesPerPerson: z.number().int().min(1).max(4)
|
|
257
|
+
maxServicesPerPerson: z.number().int().min(1).max(4),
|
|
258
|
+
adultTogether: z.boolean()
|
|
256
259
|
}),
|
|
257
260
|
window: z.object({
|
|
258
261
|
rangeDays: z.number().int().min(1).max(62),
|
|
@@ -292,7 +295,8 @@ const schema = z.object({
|
|
|
292
295
|
firstAvailableFaces: z.boolean(),
|
|
293
296
|
stylistEdgeFade: z.boolean(),
|
|
294
297
|
guestParty: z.boolean(),
|
|
295
|
-
childMenuFirst: z.boolean()
|
|
298
|
+
childMenuFirst: z.boolean(),
|
|
299
|
+
wideLayouts: z.boolean()
|
|
296
300
|
}),
|
|
297
301
|
consent: z.object({
|
|
298
302
|
termsUrl: z.string().min(1).nullable(),
|
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 /**\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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"}
|
|
1
|
+
{"version":3,"file":"config.mjs","names":[],"sources":["../../src/core/config.ts"],"sourcesContent":["/**\n * What a site tells the booking package about itself.\n *\n * `BookingConfig` is CLIENT-SAFE by construction: it is serialised into the\n * page as a prop, so it never holds a key, a session secret or a cache handle.\n * Those live in `BookingServerOptions`, which only `/next` knows (P4).\n *\n * The package reads no environment variable and no CMS. A site resolves its\n * own switches (a handoff URL, whether accounts are on, the contact block) and\n * passes the answers in.\n *\n * Every field but `timeZone` has a default. `resolveBookingConfig` fills them,\n * validates the whole object with zod and freezes it, so a config that reached\n * a factory is one that passed.\n */\n\nimport { z } from 'zod';\nimport type { BookingLabels } from './labels';\n\n/** A service group, in display order. Medal's free-string `category` maps onto `key`. */\nexport interface BookingCategory {\n key: string;\n /** Who the group is for. `child` is the kids' path: guest children, `?antall=`, the held link service. */\n audience: 'child' | 'adult' | 'any';\n /**\n * For a child's group: which adult group a service «grows up» into once the\n * child has outgrown the kids' menu. A plain key maps every service in the\n * group; a list maps by a word in the service name, first match wins\n * (`{ nameIncludes: 'gutt', category: 'herre' }`). Case-insensitive.\n */\n adultEquivalent?: string | ReadonlyArray<{ nameIncludes: string; category: string }>;\n}\n\n/** A named part of the day on the business's clock, in local hours `[from, to)`. */\nexport interface BookingDaypart {\n key: string;\n from: number;\n to: number;\n}\n\nexport interface BookingConfig {\n /** IANA zone the business keeps its hours in. Required. */\n timeZone: string;\n /** BCP-47, for every `Intl` formatter and the label pack. */\n locale: string;\n /** ISO 4217. Amounts stay in minor units (øre, pence) on the wire. */\n currency: string;\n /** `NO` is the only strict rule set in 0.x; every other country is `loose`. */\n phone: { country: string; validate: 'strict' | 'loose' };\n /** URLs this site serves. */\n paths: {\n booking: string;\n /** Prefix of the manage page; the token is the next segment. */\n manage: string;\n /** `null` = no customer portal. */\n portal: string | null;\n portalLogin: string;\n api: string;\n portalApi: string;\n avatar: string;\n };\n /**\n * The query keys a link may carry into the booking page.\n *\n * `category`, `service`, `stylist`, `party` and `who` are the marketing\n * deep-link keys; `rebookService` / `rebookStylist` are the portal's «book\n * again» link, read by the wizard's own prefill; `resume` marks a return\n * from an external login; `time` is reserved for a pre-picked start.\n */\n query: {\n category: string;\n service: string;\n stylist: string;\n party: string;\n who: string;\n time: string;\n resume: string;\n rebookService: string;\n rebookStylist: string;\n };\n /** The values `query.who` accepts. */\n whoValues: { child: string; adult: string };\n /** Service groups, in display order. */\n categories: BookingCategory[];\n /** Where an unknown Medal category lands. Must be one of `categories`. */\n fallbackCategory: string;\n /** Step 1's rules. */\n party: {\n maxPeople: number;\n allowParallel: boolean;\n askWhoFirst: boolean;\n /**\n * How many services one person may book as a single visit (1–4; Medal's\n * cap is 4). `1` — the default — is the one-tap service step: a tap\n * answers it. Above `1`, `<BookingWizard>` draws the multi-select step:\n * each person ticks up to this many, done back to back as one visit.\n */\n maxServicesPerPerson: number;\n /**\n * A party with a grown-up in it starts «Samtidig» (`partyMode:\n * 'parallel'`): the child in one chair, the parent in the next, done\n * together. Only until the parent picks a mode, or a stylist link names\n * one stylist (one stylist cannot cut two people at once). Needs\n * `allowParallel`. Off by default: everyone else keeps «Rett etter\n * hverandre».\n */\n adultTogether: boolean;\n };\n /** The bookable window and the page's prefetch. */\n window: { rangeDays: number; prefetchLimit: number; prefetchCategory: string | null };\n /** Named day parts for the time step, contiguous from 0 to 24. */\n dayparts: BookingDaypart[];\n /** Set = the booking path hands off (307) to this URL instead of rendering. */\n handoffUrl: string | null;\n /** The business's own contact, for «call us» and the unavailable screen. */\n contact: { phone: string | null; address: string | null; name: string };\n portal: {\n enabled: boolean;\n methods: Array<'email_code' | 'vipps'>;\n /** The session cookie. */\n cookieName: string;\n /** Where a login that left the site (Vipps) returns to; read once. */\n nextCookieName: string;\n /** Must be `__Host-`-prefixed: Secure, `Path=/`, no `Domain`. */\n vippsBindingCookieName: string;\n vippsLinkCookieName: string;\n /**\n * Where a finished login may send the visitor. `exact` matches the\n * pathname only (the booking page, so a deeper manage URL never\n * qualifies); `prefixes` matches anything below.\n */\n returnPaths: { exact: string[]; prefixes: string[] };\n };\n /**\n * Booking only for a logged-in parent: the details step becomes the login\n * until there is a session, and `/create` refuses without one.\n */\n account: { required: boolean };\n /**\n * Opt-in screen features, all off by default so a site's wizard only\n * changes when it asks:\n * - `recap` — the details step opens with what is being booked (day, hours,\n * who, stylist, total, «edit», and a swap to another stylist free at the\n * same minute when «first available» picked one).\n * - `soonest` — the time step leads with the earliest free starts.\n * - `dayFullness` — each day chip shows how much room the day has.\n * - `summaryDetail` — the sticky bar's time and price on a second line.\n * - `hideDisabledNext` — the bar's «next» only while it does something.\n * - `firstAvailableFaces` — «first available» drawn as the qualified\n * stylists' faces.\n * - `stylistEdgeFade` — the phone stylist row fades at its edge.\n * - `guestParty` — a guest picks a number of children and «me too» instead\n * of the chips, so a parent and child book together without an account.\n * - `childMenuFirst` — a child's service list leads with the children's and\n * anyone's groups; the grown-ups' groups move below the «usually not for\n * this age» divider, still one tap away.\n * - `wideLayouts` — the stylist step keeps its row of faces at every width\n * (the chosen stylist's bio under it), and the time step goes\n * `layout=\"adaptive\"`: days beside the times on an iPad, the whole week as\n * a grid of times on a desktop. Needs the `time.week.*` labels.\n */\n screens: {\n recap: boolean;\n soonest: boolean;\n dayFullness: boolean;\n summaryDetail: boolean;\n hideDisabledNext: boolean;\n firstAvailableFaces: boolean;\n stylistEdgeFade: boolean;\n guestParty: boolean;\n childMenuFirst: boolean;\n wideLayouts: boolean;\n };\n consent: {\n termsUrl: string | null;\n /** `null` = no marketing box. */\n marketing: { text: string; version: string } | null;\n };\n /** Namespaces browser storage keys: `<ns>:booking:draft`, … */\n storageNamespace: string;\n /**\n * `prodId` stamps every calendar file. `uidDomain` is for UIDs the package\n * mints itself (`/react`, 0.1.0); a booking id passed in as `uid` is used as\n * is, so a calendar entry made before a site moved onto the package is still\n * the same entry after it.\n */\n ics: { prodId: string; uidDomain: string };\n monitoring: { enabled: boolean; sampleRate: number };\n /** Copy, merged over the built-in pack for `locale`. */\n labels?: Partial<BookingLabels>;\n}\n\ntype DeepPartial<T> = T extends readonly unknown[]\n ? T\n : T extends object\n ? { [K in keyof T]?: DeepPartial<T[K]> }\n : T;\n\n/** What a site passes: `timeZone`, and whatever it wants to change. */\nexport type BookingConfigInput = { timeZone: string } & DeepPartial<\n Omit<BookingConfig, 'timeZone'>\n>;\n\nconst DEFAULT_PATHS: BookingConfig['paths'] = {\n booking: '/bestill',\n manage: '/bestill/administrer',\n portal: '/min-side',\n portalLogin: '/min-side/logg-inn',\n api: '/api/booking',\n portalApi: '/api/portal',\n avatar: '/api/booking/avatar',\n};\n\n/** Everything but `timeZone` (required) and the portal cookie names (derived). */\nfunction defaults(): Omit<BookingConfig, 'timeZone' | 'portal'> & {\n portal: Omit<\n BookingConfig['portal'],\n 'nextCookieName' | 'vippsBindingCookieName' | 'vippsLinkCookieName' | 'returnPaths'\n >;\n} {\n return {\n locale: 'nb-NO',\n currency: 'NOK',\n phone: { country: 'NO', validate: 'strict' },\n paths: { ...DEFAULT_PATHS },\n query: {\n category: 'kategori',\n service: 'tjeneste',\n stylist: 'frisor',\n party: 'antall',\n who: 'hvem',\n time: 'tid',\n resume: 'resume',\n rebookService: 'service',\n rebookStylist: 'stylist',\n },\n whoValues: { child: 'barn', adult: 'voksen' },\n categories: [\n { key: 'barn', audience: 'child' },\n { key: 'annet', audience: 'any' },\n ],\n fallbackCategory: 'annet',\n party: {\n maxPeople: 3,\n allowParallel: true,\n askWhoFirst: true,\n maxServicesPerPerson: 1,\n adultTogether: false,\n },\n window: { rangeDays: 7, prefetchLimit: 4, prefetchCategory: null },\n dayparts: [\n { key: 'formiddag', from: 0, to: 12 },\n { key: 'ettermiddag', from: 12, to: 17 },\n { key: 'kveld', from: 17, to: 24 },\n ],\n handoffUrl: null,\n contact: { phone: null, address: null, name: '' },\n portal: { enabled: false, methods: ['email_code'], cookieName: 'booking_portal' },\n account: { required: false },\n screens: {\n recap: false,\n soonest: false,\n dayFullness: false,\n summaryDetail: false,\n hideDisabledNext: false,\n firstAvailableFaces: false,\n stylistEdgeFade: false,\n guestParty: false,\n childMenuFirst: false,\n wideLayouts: false,\n },\n consent: { termsUrl: null, marketing: null },\n storageNamespace: 'medal',\n ics: { prodId: '-//Medal Social//Booking//EN', uidDomain: 'booking.invalid' },\n monitoring: { enabled: true, sampleRate: 1 },\n };\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/**\n * Objects merge key by key; arrays, scalars and `null` replace. Only\n * `undefined` keeps the default — `paths.portal: null` means «no portal».\n */\nfunction merge(base: unknown, patch: unknown): unknown {\n if (patch === undefined) return base;\n if (!isRecord(base) || !isRecord(patch)) return patch;\n const out: Record<string, unknown> = { ...base };\n for (const [key, value] of Object.entries(patch)) out[key] = merge(base[key], value);\n return out;\n}\n\nfunction validTimeZone(value: string): boolean {\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: value });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction validLocale(value: string): boolean {\n try {\n return Intl.getCanonicalLocales(value).length === 1;\n } catch {\n return false;\n }\n}\n\nconst path = z.string().regex(/^\\/[^\\s?#]*$/, 'must be a root-relative path');\nconst key = z.string().regex(/^[\\w-]{1,64}$/, 'must be 1–64 word characters');\n/** Group keys and `?hvem=` values are matched against a lower-cased query value. */\nconst lowerKey = z.string().regex(/^[a-z0-9_-]{1,64}$/, 'must be 1–64 lower-case word characters');\n/** A prefix must end at a segment boundary, or `/flow` would also admit `/flow-evil`. */\nconst prefix = path.refine((value) => value.endsWith('/'), 'a prefix must end with /');\n/** The longest cookie name the config takes. */\nconst MAX_COOKIE_NAME = 128;\n/** RFC 6265 token characters, which is every name a browser keeps intact. */\nconst cookieName = z.string().regex(/^[!#$%&'*+\\-.^_`|~0-9A-Za-z]{1,128}$/);\n\n/**\n * How each secondary portal cookie name derives from `portal.cookieName` when\n * the site sets none — the prefix and suffix, so a too-long base can be\n * reported against the name the site actually wrote.\n */\nconst DERIVED_COOKIE_NAMES = {\n nextCookieName: { prefix: '', suffix: '_next' },\n vippsBindingCookieName: { prefix: '__Host-', suffix: '_vipps_bind' },\n vippsLinkCookieName: { prefix: '', suffix: '_vipps_link' },\n} as const;\n\ntype DerivedCookieName = keyof typeof DERIVED_COOKIE_NAMES;\n\n/**\n * A derived name that would be too long, said about `portal.cookieName`\n * (which the site wrote) rather than a name it never set. `null` when every\n * derived name fits — the schema then judges the characters as usual — and\n * when the base is not a string at all (a runtime input the types did not\n * catch): the schema's own issue on `portal.cookieName` says so.\n */\nfunction derivedCookieNameIssue(\n session: unknown,\n derived: readonly DerivedCookieName[]\n): { path: string; message: string } | null {\n if (typeof session !== 'string') return null;\n const tooLong = derived.filter((name) => {\n const { prefix, suffix } = DERIVED_COOKIE_NAMES[name];\n return prefix.length + session.length + suffix.length > MAX_COOKIE_NAME;\n });\n if (tooLong.length === 0) return null;\n const room = Math.min(\n ...tooLong.map((name) => {\n const { prefix, suffix } = DERIVED_COOKIE_NAMES[name];\n return MAX_COOKIE_NAME - prefix.length - suffix.length;\n })\n );\n return {\n path: 'portal.cookieName',\n message: `must be at most ${room} characters, or the derived ${tooLong\n .map((name) => `portal.${name}`)\n .join(', ')} would exceed ${MAX_COOKIE_NAME}; shorten it or set ${\n tooLong.length === 1 ? 'that name' : 'those names'\n } explicitly`,\n };\n}\n\nconst schema = z\n .object({\n timeZone: z.string().refine(validTimeZone, 'must be an IANA time zone'),\n locale: z.string().refine(validLocale, 'must be a BCP-47 locale'),\n currency: z.string().regex(/^[A-Z]{3}$/, 'must be an ISO 4217 code'),\n phone: z.object({\n country: z.string().regex(/^[A-Z]{2}$/),\n validate: z.enum(['strict', 'loose']),\n }),\n paths: z.object({\n booking: path,\n manage: path,\n portal: path.nullable(),\n portalLogin: path,\n api: path,\n portalApi: path,\n avatar: path,\n }),\n query: z.object({\n category: key,\n service: key,\n stylist: key,\n party: key,\n who: key,\n time: key,\n resume: key,\n rebookService: key,\n rebookStylist: key,\n }),\n whoValues: z.object({ child: lowerKey, adult: lowerKey }),\n categories: z\n .array(\n z.object({\n key: lowerKey,\n audience: z.enum(['child', 'adult', 'any']),\n adultEquivalent: z\n .union([\n lowerKey,\n z.array(z.object({ nameIncludes: z.string().min(1), category: lowerKey })),\n ])\n .optional(),\n })\n )\n .min(1),\n fallbackCategory: lowerKey,\n party: z.object({\n maxPeople: z.number().int().min(1).max(20),\n allowParallel: z.boolean(),\n askWhoFirst: z.boolean(),\n maxServicesPerPerson: z.number().int().min(1).max(4),\n adultTogether: z.boolean(),\n }),\n window: z.object({\n rangeDays: z.number().int().min(1).max(62),\n prefetchLimit: z.number().int().min(0).max(20),\n prefetchCategory: lowerKey.nullable(),\n }),\n dayparts: z\n .array(\n z.object({\n key,\n from: z.number().int().min(0).max(23),\n to: z.number().int().min(1).max(24),\n })\n )\n .min(1),\n handoffUrl: z.url({ protocol: /^https?$/ }).nullable(),\n contact: z.object({\n phone: z.string().nullable(),\n address: z.string().nullable(),\n name: z.string(),\n }),\n portal: z.object({\n enabled: z.boolean(),\n methods: z.array(z.enum(['email_code', 'vipps'])),\n cookieName,\n nextCookieName: cookieName,\n vippsBindingCookieName: cookieName.refine(\n (name) => name.startsWith('__Host-'),\n 'must carry the __Host- prefix'\n ),\n vippsLinkCookieName: cookieName,\n returnPaths: z.object({ exact: z.array(path), prefixes: z.array(prefix) }),\n }),\n account: z.object({ required: z.boolean() }),\n screens: z.object({\n recap: z.boolean(),\n soonest: z.boolean(),\n dayFullness: z.boolean(),\n summaryDetail: z.boolean(),\n hideDisabledNext: z.boolean(),\n firstAvailableFaces: z.boolean(),\n stylistEdgeFade: z.boolean(),\n guestParty: z.boolean(),\n childMenuFirst: z.boolean(),\n wideLayouts: z.boolean(),\n }),\n consent: z.object({\n termsUrl: z.string().min(1).nullable(),\n marketing: z.object({ text: z.string().min(1), version: z.string().min(1) }).nullable(),\n }),\n storageNamespace: key,\n ics: z.object({ prodId: z.string().regex(/^-\\/\\/.+\\/\\/.+$/), uidDomain: z.string().min(1) }),\n monitoring: z.object({ enabled: z.boolean(), sampleRate: z.number().min(0).max(1) }),\n labels: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional(),\n })\n .superRefine((config, ctx) => {\n const keys = config.categories.map((category) => category.key);\n const known = new Set(keys);\n if (known.size !== keys.length) {\n ctx.addIssue({\n code: 'custom',\n path: ['categories'],\n message: 'category keys must be unique',\n });\n }\n if (!known.has(config.fallbackCategory)) {\n ctx.addIssue({\n code: 'custom',\n path: ['fallbackCategory'],\n message: 'must be one of the categories',\n });\n }\n if (config.window.prefetchCategory !== null && !known.has(config.window.prefetchCategory)) {\n ctx.addIssue({\n code: 'custom',\n path: ['window', 'prefetchCategory'],\n message: 'must be one of the categories',\n });\n }\n config.categories.forEach((category, index) => {\n const targets =\n category.adultEquivalent === undefined\n ? []\n : typeof category.adultEquivalent === 'string'\n ? [category.adultEquivalent]\n : category.adultEquivalent.map((rule) => rule.category);\n for (const target of targets) {\n if (!known.has(target)) {\n ctx.addIssue({\n code: 'custom',\n path: ['categories', index, 'adultEquivalent'],\n message: `«${target}» is not one of the categories`,\n });\n }\n }\n });\n // Contiguous and covering the whole day, so every hour has exactly one part.\n let hour = 0;\n config.dayparts.forEach((part, index) => {\n if (part.from !== hour || part.to <= part.from) {\n ctx.addIssue({\n code: 'custom',\n path: ['dayparts', index],\n message: 'dayparts must run contiguously from 0 to 24',\n });\n }\n hour = part.to;\n });\n if (hour !== 24) {\n ctx.addIssue({ code: 'custom', path: ['dayparts'], message: 'dayparts must end at 24' });\n }\n if (config.portal.methods.length === 0 && config.portal.enabled) {\n ctx.addIssue({\n code: 'custom',\n path: ['portal', 'methods'],\n message: 'an enabled portal needs a login method',\n });\n }\n if (config.account.required && !config.portal.enabled) {\n ctx.addIssue({\n code: 'custom',\n path: ['account', 'required'],\n message: 'account.required needs portal.enabled',\n });\n }\n });\n\nfunction deepFreeze<T>(value: T): T {\n if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {\n Object.freeze(value);\n for (const child of Object.values(value)) deepFreeze(child);\n }\n return value;\n}\n\n/** Thrown with zod's issue list when a config does not validate. */\nexport class BookingConfigError extends Error {\n readonly issues: ReadonlyArray<{ path: string; message: string }>;\n\n constructor(issues: ReadonlyArray<{ path: string; message: string }>) {\n super(\n `Invalid booking config:\\n${issues.map((issue) => ` ${issue.path}: ${issue.message}`).join('\\n')}`\n );\n this.name = 'BookingConfigError';\n this.issues = issues;\n }\n}\n\n/**\n * The site's partial config with every default filled, validated and frozen.\n *\n * The portal's three secondary cookie names derive from `cookieName` unless\n * set, and a login may return to the booking page unless `returnPaths` says\n * otherwise. Idempotent: a resolved config resolves to an equal one.\n */\nexport function resolveBookingConfig(input: BookingConfigInput): Readonly<BookingConfig> {\n const merged = merge(defaults(), input) as Omit<BookingConfig, 'portal'> & {\n portal: Partial<BookingConfig['portal']> & Pick<BookingConfig['portal'], 'cookieName'>;\n };\n const session = merged.portal.cookieName;\n const candidate = {\n ...merged,\n portal: {\n ...merged.portal,\n nextCookieName: merged.portal.nextCookieName ?? `${session}_next`,\n vippsBindingCookieName:\n merged.portal.vippsBindingCookieName ?? `__Host-${session}_vipps_bind`,\n vippsLinkCookieName: merged.portal.vippsLinkCookieName ?? `${session}_vipps_link`,\n returnPaths: {\n exact: merged.portal.returnPaths?.exact ?? [merged.paths.booking],\n prefixes: merged.portal.returnPaths?.prefixes ?? [],\n },\n },\n };\n const derived = (Object.keys(DERIVED_COOKIE_NAMES) as DerivedCookieName[]).filter(\n (name) => merged.portal[name] === undefined\n );\n const result = schema.safeParse(candidate);\n if (!result.success) {\n const lengthIssue = derivedCookieNameIssue(session, derived);\n const issues = result.error.issues\n .map((issue) => ({ path: issue.path.join('.'), message: issue.message }))\n // The derived names' own complaints are the base name's, said once.\n .filter(\n (issue) => lengthIssue === null || !derived.some((name) => issue.path === `portal.${name}`)\n );\n throw new BookingConfigError(lengthIssue === null ? issues : [lengthIssue, ...issues]);\n }\n return deepFreeze(result.data as BookingConfig);\n}\n"],"mappings":"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"}
|
|
@@ -69,6 +69,11 @@ export interface WizardDraft {
|
|
|
69
69
|
/** The visitor's preference — `null` is «Første ledige». */
|
|
70
70
|
resourceId: string | null;
|
|
71
71
|
partyMode: 'sequential' | 'parallel';
|
|
72
|
+
/**
|
|
73
|
+
* The parent picked `partyMode` (not `party.adultTogether`). Absent in a
|
|
74
|
+
* draft written before the field existed, which restores as chosen.
|
|
75
|
+
*/
|
|
76
|
+
partyModeChosen?: boolean;
|
|
72
77
|
startTs: number | null;
|
|
73
78
|
/** Who the chosen slot resolved to, which is what gets submitted. */
|
|
74
79
|
resolvedResourceId: string | null;
|
|
@@ -80,6 +80,7 @@ function asDraft(value, now) {
|
|
|
80
80
|
items,
|
|
81
81
|
resourceId: typeof draft.resourceId === "string" ? draft.resourceId : null,
|
|
82
82
|
partyMode: draft.partyMode === "parallel" ? "parallel" : "sequential",
|
|
83
|
+
...typeof draft.partyModeChosen === "boolean" ? { partyModeChosen: draft.partyModeChosen } : {},
|
|
83
84
|
startTs: visitsWhole && typeof draft.startTs === "number" ? draft.startTs : null,
|
|
84
85
|
resolvedResourceId: typeof draft.resolvedResourceId === "string" ? draft.resolvedResourceId : null,
|
|
85
86
|
partyResourceIds,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"draft-store.mjs","names":[],"sources":["../../src/core/draft-store.ts"],"sourcesContent":["/**\n * The half-built booking a Vipps login leaves behind, kept for the length of\n * one round trip.\n *\n * The login sheet offers «Fortsett med Vipps», and Vipps is a whole navigation away:\n * the browser goes to Medal, then to Vipps, then back to `/min-side/vipps`,\n * which hands it on to wherever the login said it started. Every answer the\n * parent has given by then — the service, the stylist, the hour — lives in a\n * `useReducer` in a component that no longer exists. Without this they come\n * back logged in to an empty step 1, which is a worse experience than never\n * having been offered the login.\n *\n * `sessionStorage`, for the same reasons `rebook-store.ts` uses it: one tab,\n * gone with it, and never in a URL. The draft carries a child's NAME, which is\n * personal data about a minor — a query string is copied into browser history,\n * the edge access log and every analytics pageview that records `location`, and\n * this is not.\n *\n * Read-once, like the rebook stash and for the same reason: a draft that\n * survived its login would rebuild an abandoned booking on the parent's next\n * visit to `/bestill` in the same tab.\n *\n * IDS AND INSTANTS ONLY. Nothing here is trusted on the way back in: the\n * service ids are looked up in the catalogue the page just fetched, and the\n * restore runs through the machine's own actions, so a draft naming a service\n * the salon has since retired rebuilds nothing rather than a basket the wizard\n * could not have produced by tapping.\n *\n * Every path falls back to «no draft» rather than throwing — no storage during\n * the server render, Safari private mode, storage turned off — because the\n * worst case is a parent answering three questions they had answered once.\n */\n\n/** `<namespace>:booking:draft`. A storage key, not a credential. */\nexport function draftStorageKey(namespace: string): string {\n return `${namespace}:booking:draft`;\n}\n\n/** How long a draft is worth restoring. A Vipps login is a phone unlock and a\n * confirmation; anything older than this is a tab left open over lunch, and the\n * hour in it has very likely gone. A draft dated in the future is refused\n * outright — see `asDraft`. */\nexport const DRAFT_MAX_AGE_MS = 30 * 60 * 1000;\n\n/** One line item, as little of it as a rebuild needs. */\nexport interface WizardDraftItem {\n serviceId: string;\n /**\n * The rest of the person's visit after `serviceId`, in order. Absent for a\n * one-service line (and in a draft written before visits had extras), never\n * `[]`. Ids only, like `serviceId`: a restore looks each one up again.\n */\n extraServiceIds?: string[];\n bookedForName: string | null;\n bookedForBirthYear: number | null;\n /**\n * A grown-up's line (`true`) or a child's (`false`); `null` in a draft\n * written before the field existed. A restore reseats a guest from it, so a\n * parent who changed «Voksen» to «1 barn» comes back from Vipps as they left,\n * not as the link they arrived on said.\n */\n adult?: boolean | null;\n /**\n * The saved child the line was for (`bookedForPersonId`); `null` for a guest\n * or a draft written before the field existed. Never trusted on the way\n * back: a restore reseats it only when the logged-in parent's own family has\n * a child with this id.\n */\n personId?: string | null;\n}\n\nexport interface WizardDraft {\n items: WizardDraftItem[];\n /** The visitor's preference — `null` is «Første ledige». */\n resourceId: string | null;\n partyMode: 'sequential' | 'parallel';\n startTs: number | null;\n /** Who the chosen slot resolved to, which is what gets submitted. */\n resolvedResourceId: string | null;\n /** One stylist per line item, for a party; `null` for everything else. */\n partyResourceIds: string[] | null;\n /** When it was written, so a stale tab does not resurrect last week's hour. */\n savedAt: number;\n}\n\n/** The most extras a line can carry — the engine's own ceiling. */\nconst MAX_DRAFT_EXTRAS = 3;\n\n/**\n * A line's extras, or `null` to drop the field: anything but a short list of\n * non-empty strings is a jar somebody edited, and the line is still worth\n * restoring as its one service — without the hour, which was chosen for the\n * longer visit (see `asDraft`).\n */\nfunction asExtraServiceIds(value: unknown): string[] | null {\n if (!Array.isArray(value) || value.length === 0 || value.length > MAX_DRAFT_EXTRAS) return null;\n return value.every((id) => typeof id === 'string' && id.length > 0) ? [...value] : null;\n}\n\n/** The shape check, done here so nothing downstream has to trust the jar. */\nfunction asDraft(value: unknown, now: number): WizardDraft | null {\n if (typeof value !== 'object' || value === null) return null;\n const draft = value as Partial<WizardDraft>;\n // Both ends of the window. A draft older than the lifetime is a tab left open\n // over lunch — and one stamped in the FUTURE is a clock that has since been\n // corrected backwards, whose negative age would pass an «older than» check\n // indefinitely and resurrect an obsolete hour hours later.\n if (typeof draft.savedAt !== 'number') return null;\n const age = now - draft.savedAt;\n if (age < 0 || age > DRAFT_MAX_AGE_MS) return null;\n if (!Array.isArray(draft.items) || draft.items.length === 0) return null;\n const items: WizardDraftItem[] = [];\n // Whether every line's visit survived the check whole. A dropped extra makes\n // the visit shorter than the one the stored hour was chosen for.\n let visitsWhole = true;\n for (const item of draft.items) {\n if (typeof item?.serviceId !== 'string' || item.serviceId.length === 0) return null;\n const extraServiceIds = asExtraServiceIds(item.extraServiceIds);\n if (item.extraServiceIds !== undefined && extraServiceIds === null) visitsWhole = false;\n items.push({\n serviceId: item.serviceId,\n ...(extraServiceIds === null ? {} : { extraServiceIds }),\n bookedForName: typeof item.bookedForName === 'string' ? item.bookedForName : null,\n bookedForBirthYear:\n typeof item.bookedForBirthYear === 'number' && Number.isFinite(item.bookedForBirthYear)\n ? item.bookedForBirthYear\n : null,\n adult: typeof item.adult === 'boolean' ? item.adult : null,\n personId:\n typeof item.personId === 'string' && item.personId.length > 0 ? item.personId : null,\n });\n }\n const partyResourceIds =\n Array.isArray(draft.partyResourceIds) &&\n draft.partyResourceIds.every((id) => typeof id === 'string')\n ? draft.partyResourceIds\n : null;\n return {\n items,\n resourceId: typeof draft.resourceId === 'string' ? draft.resourceId : null,\n partyMode: draft.partyMode === 'parallel' ? 'parallel' : 'sequential',\n startTs: visitsWhole && typeof draft.startTs === 'number' ? draft.startTs : null,\n resolvedResourceId:\n typeof draft.resolvedResourceId === 'string' ? draft.resolvedResourceId : null,\n partyResourceIds,\n savedAt: draft.savedAt,\n };\n}\n\nexport interface DraftStore {\n readonly DRAFT_STORAGE_KEY: string;\n stashDraft(draft: Omit<WizardDraft, 'savedAt'>, now?: number): void;\n takeDraft(now?: number): WizardDraft | null;\n clearDraft(): void;\n}\n\n/** The draft store under `<namespace>:booking:draft`. */\nexport function createDraftStore(namespace: string): DraftStore {\n const STORAGE_KEY = draftStorageKey(namespace);\n\n /** Write the draft, replacing whatever was there. One tab holds one booking. */\n function stashDraft(draft: Omit<WizardDraft, 'savedAt'>, now: number = Date.now()): void {\n if (typeof window === 'undefined') return;\n try {\n window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ ...draft, savedAt: now }));\n } catch {\n // The parent answers the three questions again. Nothing is lost that was\n // not already only in a browser tab.\n }\n }\n\n /** The draft, and it is gone afterwards. */\n function takeDraft(now: number = Date.now()): WizardDraft | null {\n if (typeof window === 'undefined') return null;\n try {\n const raw = window.sessionStorage.getItem(STORAGE_KEY);\n window.sessionStorage.removeItem(STORAGE_KEY);\n if (raw === null) return null;\n return asDraft(JSON.parse(raw), now);\n } catch {\n // A malformed value has already been removed above where it could be.\n return null;\n }\n }\n\n /** Forget any draft, for a booking that has been made or abandoned. */\n function clearDraft(): void {\n if (typeof window === 'undefined') return;\n try {\n window.sessionStorage.removeItem(STORAGE_KEY);\n } catch {\n // Nothing to do: a draft that cannot be removed also could not be written.\n }\n }\n\n return { DRAFT_STORAGE_KEY: STORAGE_KEY, stashDraft, takeDraft, clearDraft };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCA,SAAgB,gBAAgB,WAA2B;CACzD,OAAO,GAAG,UAAU;AACtB;;;;;AAMA,MAAa,mBAAmB;;AA4ChC,MAAM,mBAAmB;;;;;;;AAQzB,SAAS,kBAAkB,OAAiC;CAC1D,IAAI,CAAC,MAAM,QAAQ,KAAK,KAAK,MAAM,WAAW,KAAK,MAAM,SAAS,kBAAkB,OAAO;CAC3F,OAAO,MAAM,OAAO,OAAO,OAAO,OAAO,YAAY,GAAG,SAAS,CAAC,IAAI,CAAC,GAAG,KAAK,IAAI;AACrF;;AAGA,SAAS,QAAQ,OAAgB,KAAiC;CAChE,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO;CACxD,MAAM,QAAQ;CAKd,IAAI,OAAO,MAAM,YAAY,UAAU,OAAO;CAC9C,MAAM,MAAM,MAAM,MAAM;CACxB,IAAI,MAAM,KAAK,MAAA,MAAwB,OAAO;CAC9C,IAAI,CAAC,MAAM,QAAQ,MAAM,KAAK,KAAK,MAAM,MAAM,WAAW,GAAG,OAAO;CACpE,MAAM,QAA2B,CAAC;CAGlC,IAAI,cAAc;CAClB,KAAK,MAAM,QAAQ,MAAM,OAAO;EAC9B,IAAI,OAAO,MAAM,cAAc,YAAY,KAAK,UAAU,WAAW,GAAG,OAAO;EAC/E,MAAM,kBAAkB,kBAAkB,KAAK,eAAe;EAC9D,IAAI,KAAK,oBAAoB,KAAA,KAAa,oBAAoB,MAAM,cAAc;EAClF,MAAM,KAAK;GACT,WAAW,KAAK;GAChB,GAAI,oBAAoB,OAAO,CAAC,IAAI,EAAE,gBAAgB;GACtD,eAAe,OAAO,KAAK,kBAAkB,WAAW,KAAK,gBAAgB;GAC7E,oBACE,OAAO,KAAK,uBAAuB,YAAY,OAAO,SAAS,KAAK,kBAAkB,IAClF,KAAK,qBACL;GACN,OAAO,OAAO,KAAK,UAAU,YAAY,KAAK,QAAQ;GACtD,UACE,OAAO,KAAK,aAAa,YAAY,KAAK,SAAS,SAAS,IAAI,KAAK,WAAW;EACpF,CAAC;CACH;CACA,MAAM,mBACJ,MAAM,QAAQ,MAAM,gBAAgB,KACpC,MAAM,iBAAiB,OAAO,OAAO,OAAO,OAAO,QAAQ,IACvD,MAAM,mBACN;CACN,OAAO;EACL;EACA,YAAY,OAAO,MAAM,eAAe,WAAW,MAAM,aAAa;EACtE,WAAW,MAAM,cAAc,aAAa,aAAa;EACzD,SAAS,eAAe,OAAO,MAAM,YAAY,WAAW,MAAM,UAAU;EAC5E,oBACE,OAAO,MAAM,uBAAuB,WAAW,MAAM,qBAAqB;EAC5E;EACA,SAAS,MAAM;CACjB;AACF;;AAUA,SAAgB,iBAAiB,WAA+B;CAC9D,MAAM,cAAc,gBAAgB,SAAS;;CAG7C,SAAS,WAAW,OAAqC,MAAc,KAAK,IAAI,GAAS;EACvF,IAAI,OAAO,WAAW,aAAa;EACnC,IAAI;GACF,OAAO,eAAe,QAAQ,aAAa,KAAK,UAAU;IAAE,GAAG;IAAO,SAAS;GAAI,CAAC,CAAC;EACvF,QAAQ,CAGR;CACF;;CAGA,SAAS,UAAU,MAAc,KAAK,IAAI,GAAuB;EAC/D,IAAI,OAAO,WAAW,aAAa,OAAO;EAC1C,IAAI;GACF,MAAM,MAAM,OAAO,eAAe,QAAQ,WAAW;GACrD,OAAO,eAAe,WAAW,WAAW;GAC5C,IAAI,QAAQ,MAAM,OAAO;GACzB,OAAO,QAAQ,KAAK,MAAM,GAAG,GAAG,GAAG;EACrC,QAAQ;GAEN,OAAO;EACT;CACF;;CAGA,SAAS,aAAmB;EAC1B,IAAI,OAAO,WAAW,aAAa;EACnC,IAAI;GACF,OAAO,eAAe,WAAW,WAAW;EAC9C,QAAQ,CAER;CACF;CAEA,OAAO;EAAE,mBAAmB;EAAa;EAAY;EAAW;CAAW;AAC7E"}
|
|
1
|
+
{"version":3,"file":"draft-store.mjs","names":[],"sources":["../../src/core/draft-store.ts"],"sourcesContent":["/**\n * The half-built booking a Vipps login leaves behind, kept for the length of\n * one round trip.\n *\n * The login sheet offers «Fortsett med Vipps», and Vipps is a whole navigation away:\n * the browser goes to Medal, then to Vipps, then back to `/min-side/vipps`,\n * which hands it on to wherever the login said it started. Every answer the\n * parent has given by then — the service, the stylist, the hour — lives in a\n * `useReducer` in a component that no longer exists. Without this they come\n * back logged in to an empty step 1, which is a worse experience than never\n * having been offered the login.\n *\n * `sessionStorage`, for the same reasons `rebook-store.ts` uses it: one tab,\n * gone with it, and never in a URL. The draft carries a child's NAME, which is\n * personal data about a minor — a query string is copied into browser history,\n * the edge access log and every analytics pageview that records `location`, and\n * this is not.\n *\n * Read-once, like the rebook stash and for the same reason: a draft that\n * survived its login would rebuild an abandoned booking on the parent's next\n * visit to `/bestill` in the same tab.\n *\n * IDS AND INSTANTS ONLY. Nothing here is trusted on the way back in: the\n * service ids are looked up in the catalogue the page just fetched, and the\n * restore runs through the machine's own actions, so a draft naming a service\n * the salon has since retired rebuilds nothing rather than a basket the wizard\n * could not have produced by tapping.\n *\n * Every path falls back to «no draft» rather than throwing — no storage during\n * the server render, Safari private mode, storage turned off — because the\n * worst case is a parent answering three questions they had answered once.\n */\n\n/** `<namespace>:booking:draft`. A storage key, not a credential. */\nexport function draftStorageKey(namespace: string): string {\n return `${namespace}:booking:draft`;\n}\n\n/** How long a draft is worth restoring. A Vipps login is a phone unlock and a\n * confirmation; anything older than this is a tab left open over lunch, and the\n * hour in it has very likely gone. A draft dated in the future is refused\n * outright — see `asDraft`. */\nexport const DRAFT_MAX_AGE_MS = 30 * 60 * 1000;\n\n/** One line item, as little of it as a rebuild needs. */\nexport interface WizardDraftItem {\n serviceId: string;\n /**\n * The rest of the person's visit after `serviceId`, in order. Absent for a\n * one-service line (and in a draft written before visits had extras), never\n * `[]`. Ids only, like `serviceId`: a restore looks each one up again.\n */\n extraServiceIds?: string[];\n bookedForName: string | null;\n bookedForBirthYear: number | null;\n /**\n * A grown-up's line (`true`) or a child's (`false`); `null` in a draft\n * written before the field existed. A restore reseats a guest from it, so a\n * parent who changed «Voksen» to «1 barn» comes back from Vipps as they left,\n * not as the link they arrived on said.\n */\n adult?: boolean | null;\n /**\n * The saved child the line was for (`bookedForPersonId`); `null` for a guest\n * or a draft written before the field existed. Never trusted on the way\n * back: a restore reseats it only when the logged-in parent's own family has\n * a child with this id.\n */\n personId?: string | null;\n}\n\nexport interface WizardDraft {\n items: WizardDraftItem[];\n /** The visitor's preference — `null` is «Første ledige». */\n resourceId: string | null;\n partyMode: 'sequential' | 'parallel';\n /**\n * The parent picked `partyMode` (not `party.adultTogether`). Absent in a\n * draft written before the field existed, which restores as chosen.\n */\n partyModeChosen?: boolean;\n startTs: number | null;\n /** Who the chosen slot resolved to, which is what gets submitted. */\n resolvedResourceId: string | null;\n /** One stylist per line item, for a party; `null` for everything else. */\n partyResourceIds: string[] | null;\n /** When it was written, so a stale tab does not resurrect last week's hour. */\n savedAt: number;\n}\n\n/** The most extras a line can carry — the engine's own ceiling. */\nconst MAX_DRAFT_EXTRAS = 3;\n\n/**\n * A line's extras, or `null` to drop the field: anything but a short list of\n * non-empty strings is a jar somebody edited, and the line is still worth\n * restoring as its one service — without the hour, which was chosen for the\n * longer visit (see `asDraft`).\n */\nfunction asExtraServiceIds(value: unknown): string[] | null {\n if (!Array.isArray(value) || value.length === 0 || value.length > MAX_DRAFT_EXTRAS) return null;\n return value.every((id) => typeof id === 'string' && id.length > 0) ? [...value] : null;\n}\n\n/** The shape check, done here so nothing downstream has to trust the jar. */\nfunction asDraft(value: unknown, now: number): WizardDraft | null {\n if (typeof value !== 'object' || value === null) return null;\n const draft = value as Partial<WizardDraft>;\n // Both ends of the window. A draft older than the lifetime is a tab left open\n // over lunch — and one stamped in the FUTURE is a clock that has since been\n // corrected backwards, whose negative age would pass an «older than» check\n // indefinitely and resurrect an obsolete hour hours later.\n if (typeof draft.savedAt !== 'number') return null;\n const age = now - draft.savedAt;\n if (age < 0 || age > DRAFT_MAX_AGE_MS) return null;\n if (!Array.isArray(draft.items) || draft.items.length === 0) return null;\n const items: WizardDraftItem[] = [];\n // Whether every line's visit survived the check whole. A dropped extra makes\n // the visit shorter than the one the stored hour was chosen for.\n let visitsWhole = true;\n for (const item of draft.items) {\n if (typeof item?.serviceId !== 'string' || item.serviceId.length === 0) return null;\n const extraServiceIds = asExtraServiceIds(item.extraServiceIds);\n if (item.extraServiceIds !== undefined && extraServiceIds === null) visitsWhole = false;\n items.push({\n serviceId: item.serviceId,\n ...(extraServiceIds === null ? {} : { extraServiceIds }),\n bookedForName: typeof item.bookedForName === 'string' ? item.bookedForName : null,\n bookedForBirthYear:\n typeof item.bookedForBirthYear === 'number' && Number.isFinite(item.bookedForBirthYear)\n ? item.bookedForBirthYear\n : null,\n adult: typeof item.adult === 'boolean' ? item.adult : null,\n personId:\n typeof item.personId === 'string' && item.personId.length > 0 ? item.personId : null,\n });\n }\n const partyResourceIds =\n Array.isArray(draft.partyResourceIds) &&\n draft.partyResourceIds.every((id) => typeof id === 'string')\n ? draft.partyResourceIds\n : null;\n return {\n items,\n resourceId: typeof draft.resourceId === 'string' ? draft.resourceId : null,\n partyMode: draft.partyMode === 'parallel' ? 'parallel' : 'sequential',\n ...(typeof draft.partyModeChosen === 'boolean'\n ? { partyModeChosen: draft.partyModeChosen }\n : {}),\n startTs: visitsWhole && typeof draft.startTs === 'number' ? draft.startTs : null,\n resolvedResourceId:\n typeof draft.resolvedResourceId === 'string' ? draft.resolvedResourceId : null,\n partyResourceIds,\n savedAt: draft.savedAt,\n };\n}\n\nexport interface DraftStore {\n readonly DRAFT_STORAGE_KEY: string;\n stashDraft(draft: Omit<WizardDraft, 'savedAt'>, now?: number): void;\n takeDraft(now?: number): WizardDraft | null;\n clearDraft(): void;\n}\n\n/** The draft store under `<namespace>:booking:draft`. */\nexport function createDraftStore(namespace: string): DraftStore {\n const STORAGE_KEY = draftStorageKey(namespace);\n\n /** Write the draft, replacing whatever was there. One tab holds one booking. */\n function stashDraft(draft: Omit<WizardDraft, 'savedAt'>, now: number = Date.now()): void {\n if (typeof window === 'undefined') return;\n try {\n window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ ...draft, savedAt: now }));\n } catch {\n // The parent answers the three questions again. Nothing is lost that was\n // not already only in a browser tab.\n }\n }\n\n /** The draft, and it is gone afterwards. */\n function takeDraft(now: number = Date.now()): WizardDraft | null {\n if (typeof window === 'undefined') return null;\n try {\n const raw = window.sessionStorage.getItem(STORAGE_KEY);\n window.sessionStorage.removeItem(STORAGE_KEY);\n if (raw === null) return null;\n return asDraft(JSON.parse(raw), now);\n } catch {\n // A malformed value has already been removed above where it could be.\n return null;\n }\n }\n\n /** Forget any draft, for a booking that has been made or abandoned. */\n function clearDraft(): void {\n if (typeof window === 'undefined') return;\n try {\n window.sessionStorage.removeItem(STORAGE_KEY);\n } catch {\n // Nothing to do: a draft that cannot be removed also could not be written.\n }\n }\n\n return { DRAFT_STORAGE_KEY: STORAGE_KEY, stashDraft, takeDraft, clearDraft };\n}\n"],"mappings":"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"}
|
package/dist/core/machine.d.mts
CHANGED
|
@@ -159,6 +159,12 @@ export interface WizardState {
|
|
|
159
159
|
*/
|
|
160
160
|
stylistAnswered: boolean;
|
|
161
161
|
partyMode: 'sequential' | 'parallel';
|
|
162
|
+
/**
|
|
163
|
+
* The parent chose `partyMode` themselves (`setPartyMode`, or a party
|
|
164
|
+
* slot found in that mode), so `party.adultTogether` no longer picks it
|
|
165
|
+
* for them. Absent = not chosen.
|
|
166
|
+
*/
|
|
167
|
+
partyModeChosen?: boolean;
|
|
162
168
|
startTs: number | null;
|
|
163
169
|
resolvedResourceId: string | null;
|
|
164
170
|
/**
|
package/dist/core/machine.mjs
CHANGED
|
@@ -563,7 +563,7 @@ function createWizard(config) {
|
|
|
563
563
|
const index = before(person);
|
|
564
564
|
return index === -1 ? [] : extrasAt(state, index);
|
|
565
565
|
});
|
|
566
|
-
return {
|
|
566
|
+
return withAdultTogether({
|
|
567
567
|
...withParty(state, action.people.map((person) => {
|
|
568
568
|
const index = before(person);
|
|
569
569
|
return index === -1 || person.personId !== void 0 ? person : {
|
|
@@ -573,6 +573,23 @@ function createWizard(config) {
|
|
|
573
573
|
}), choices, extras),
|
|
574
574
|
step: action.advance ? "service" : state.step,
|
|
575
575
|
error: null
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* `party.adultTogether`: a party with a grown-up in it starts «Samtidig»,
|
|
580
|
+
* and goes back to «Rett etter hverandre» when the grown-up leaves it —
|
|
581
|
+
* until the parent picks a mode, which is theirs from then on. A named
|
|
582
|
+
* stylist keeps the party one after another: one stylist cannot cut two
|
|
583
|
+
* people at once, and the parent asked for that one.
|
|
584
|
+
*/
|
|
585
|
+
function withAdultTogether(state) {
|
|
586
|
+
const { adultTogether, allowParallel } = config.party;
|
|
587
|
+
if (!adultTogether || !allowParallel || state.partyModeChosen) return state;
|
|
588
|
+
const mode = state.people.length > 1 && state.people.some((person) => person.adult) && state.resourceId === null ? "parallel" : "sequential";
|
|
589
|
+
return mode === state.partyMode ? state : {
|
|
590
|
+
...state,
|
|
591
|
+
...clearedSlot,
|
|
592
|
+
partyMode: mode
|
|
576
593
|
};
|
|
577
594
|
}
|
|
578
595
|
/** The held link service, if it suits this person: kids' cuts for children only. */
|
|
@@ -665,14 +682,14 @@ function createWizard(config) {
|
|
|
665
682
|
step: "when",
|
|
666
683
|
error: null
|
|
667
684
|
};
|
|
668
|
-
return {
|
|
685
|
+
return withAdultTogether({
|
|
669
686
|
...state,
|
|
670
687
|
...clearedSlot,
|
|
671
688
|
resourceId: action.resourceId,
|
|
672
689
|
stylistAnswered: true,
|
|
673
690
|
step: "when",
|
|
674
691
|
error: null
|
|
675
|
-
};
|
|
692
|
+
});
|
|
676
693
|
case "pickSlot":
|
|
677
694
|
if (state.partyMode === "parallel" && state.items.length > 1) return state;
|
|
678
695
|
return {
|
|
@@ -694,6 +711,7 @@ function createWizard(config) {
|
|
|
694
711
|
...clearedSlot,
|
|
695
712
|
...preference,
|
|
696
713
|
partyMode: action.mode,
|
|
714
|
+
partyModeChosen: true,
|
|
697
715
|
error: null
|
|
698
716
|
};
|
|
699
717
|
}
|
|
@@ -705,6 +723,7 @@ function createWizard(config) {
|
|
|
705
723
|
startTs: action.startTs,
|
|
706
724
|
partyResourceIds: action.resourceIds,
|
|
707
725
|
partyMode: action.mode,
|
|
726
|
+
partyModeChosen: true,
|
|
708
727
|
resolvedResourceId: sharedResourceId(action.resourceIds),
|
|
709
728
|
step: "details",
|
|
710
729
|
error: null
|