@medalsocial/booking 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import "../../core/range.mjs";
1
2
  import { MedalConfigError } from "../medal.mjs";
2
3
  import { MedalApiError } from "@medalsocial/sdk";
3
4
  import { NextResponse } from "next/server";
@@ -108,23 +109,47 @@ async function knownExtraServiceIds(rt, raw, serviceId) {
108
109
  return { extraServiceIds: ids };
109
110
  }
110
111
  /**
111
- * The engine's «that slot is no longer free».
112
+ * The engine's refusals that all mean «that time cannot be booked — pick
113
+ * another»: taken, already passed, beyond the booking horizon, off the slot
114
+ * grid. Each is answered the same way — fresh openings, back to the time step —
115
+ * because the visitor's only move is a different time. Read as anything else,
116
+ * the wizard said «Prøv en gang til» and kept the hour, and every retry was
117
+ * refused again: a tab left open overnight offers hours that have passed.
112
118
  *
113
- * It arrives as `CONFLICT` with `SLOT_TAKEN` in the message: the SDK exposes no
114
- * finer code (`MedalApiError.code` is the generic `CONFLICT`, shared with a
115
- * duplicate key and an unseatable party). The message is read HERE and nowhere
116
- * else, so the day the engine grows a structured code there is one line to
117
- * change.
119
+ * The API answers all four with HTTP 409 and code `CONFLICT`, the reason in
120
+ * `error.details.reason` (lower case) and the same word upper-cased as the
121
+ * message prefix (`IN_PAST: …`). Read defensively anyway — any 409 whatever
122
+ * its code, the reason in any case, the message prefix when there is no
123
+ * reason — so a change of spelling upstream degrades to «pick another time»,
124
+ * never to a retry loop. Only a 409 is ever one: a 400 or 422 is a request
125
+ * the visitor's next tap cannot fix by picking a time.
118
126
  */
119
- function isSlotTaken(error) {
120
- return error instanceof MedalApiError && error.code === "CONFLICT" && error.message.includes("SLOT_TAKEN");
127
+ const PICK_ANOTHER_TIME = /* @__PURE__ */ new Set([
128
+ "slot_taken",
129
+ "in_past",
130
+ "outside_horizon",
131
+ "off_grid"
132
+ ]);
133
+ /** The same refusals by their message prefix, for an engine that sends no `details.reason`. */
134
+ const PICK_ANOTHER_TIME_MESSAGE = /\b(?:SLOT_TAKEN|IN_PAST|OUTSIDE_HORIZON|OFF_GRID)\b/;
135
+ /** `error.details.reason` in lower case — the engine's structured refusal, when it sent one. */
136
+ function refusalReason(error) {
137
+ const details = error.details;
138
+ if (typeof details !== "object" || details === null || !("reason" in details)) return null;
139
+ return typeof details.reason === "string" ? details.reason.toLowerCase() : null;
121
140
  }
122
141
  /**
123
- * 62 days. A bound on what a visitor-controlled query string may ask the site
124
- * to fetch on their behalf — not the engine's own limit, which clamps rather
125
- * than refuses. The wizard asks for a week at a time.
142
+ * The engine's «that time is no longer bookable» (see `PICK_ANOTHER_TIME`).
143
+ *
144
+ * It arrives as a 409, shared with a duplicate key and an unseatable party. The reason is read HERE and nowhere else: structured when
145
+ * the engine sends one, else the message prefix it has always carried.
126
146
  */
127
- const MAX_RANGE_MS = 53568e5;
147
+ function isSlotTaken(error) {
148
+ if (!(error instanceof MedalApiError) || error.status !== 409) return false;
149
+ const reason = refusalReason(error);
150
+ if (reason !== null) return PICK_ANOTHER_TIME.has(reason);
151
+ return PICK_ANOTHER_TIME_MESSAGE.test(error.message);
152
+ }
128
153
  /**
129
154
  * Epoch milliseconds or an ISO 8601 string — the same two forms the engine's
130
155
  * own parser accepts, so a bound this site admits is one Medal would have
@@ -148,18 +173,36 @@ function parseRange(searchParams) {
148
173
  const toTs = parseTimestamp(searchParams.get("to_ts"));
149
174
  if (fromTs === null || toTs === null) return { error: bookingErrorResponse("invalidInput", 400, "from_ts and to_ts must be Unix milliseconds or ISO 8601 date-times") };
150
175
  if (toTs <= fromTs) return { error: bookingErrorResponse("invalidInput", 400, "to_ts must be after from_ts") };
151
- if (toTs - fromTs > MAX_RANGE_MS) return { error: bookingErrorResponse("invalidInput", 400, "Range must not exceed 62 days") };
176
+ if (toTs - fromTs > 53604e5) return { error: bookingErrorResponse("invalidInput", 400, `Range must not exceed 62 days`) };
152
177
  return {
153
178
  fromTs,
154
179
  toTs
155
180
  };
156
181
  }
182
+ /**
183
+ * Expire the slot cache and this colo's booking seeds for services just
184
+ * booked, moved or cancelled.
185
+ *
186
+ * Never throws. The write has already happened when this runs, and a cache
187
+ * that could not be told must not turn it into an error: the visitor would be
188
+ * told a booking failed that exists, and might book it again. The worst a
189
+ * failure here costs is a stale opening for a few minutes, which Medal's own
190
+ * check at the next submit refuses.
191
+ */
192
+ async function forgetBookedSlots(rt, serviceIds) {
193
+ try {
194
+ rt.catalogue.expireSlots([...serviceIds]);
195
+ await rt.seed.expireBookingSeeds([...serviceIds]);
196
+ } catch (error) {
197
+ rt.logger.error({ err: error }, "Could not expire the booking caches after a write");
198
+ }
199
+ }
157
200
  /** SHA-256, hex — for idempotency keys that must never carry what they hash. */
158
201
  async function digestOf(input) {
159
202
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
160
203
  return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
161
204
  }
162
205
  //#endregion
163
- export { bookingErrorResponse, catalogueErrorResponse, digestOf, isSlotTaken, knownExtraServiceIds, knownServiceId, parseRange };
206
+ export { bookingErrorResponse, catalogueErrorResponse, digestOf, forgetBookedSlots, isSlotTaken, knownExtraServiceIds, knownServiceId, parseRange };
164
207
 
165
208
  //# sourceMappingURL=shared.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"shared.mjs","names":[],"sources":["../../../src/next/routes/shared.ts"],"sourcesContent":["/**\n * What the booking routes are allowed to say to a browser.\n *\n * The wizard reads `error` straight into `WizardState.error`, which is a closed\n * union — `'maxParty' | 'slotTaken' | 'conflict' | 'invalidInput' |\n * 'unconfigured' | 'upstreamError'` — so the spelling here is load-bearing\n * rather than descriptive.\n *\n * The create route also answers `'inProgress'` (409, a key Medal already holds)\n * and `'accountRequired'` (401, `account.required` with nobody logged in).\n *\n * `upstreamError` is the catch-all. Something has to be in the field either\n * way, because `NextResponse.json({ error: undefined })` serialises to `{}`\n * and reads as a failure with no reason at all.\n *\n * Only the create route is held to the union. `catalogueErrorResponse` also\n * relays the upstream's own code where there is one, so the read routes can\n * say words the wizard has never heard of — which is safe precisely because\n * nothing is reading them into `WizardState`.\n *\n * `message` appears only when THIS file wrote it. Medal's own error bodies are\n * written for the integrator holding the API key — they name workspaces, ids\n * and internal field paths — and none of that belongs in front of a visitor.\n */\n\nimport { MedalApiError } from '@medalsocial/sdk';\nimport { NextResponse } from 'next/server';\nimport { MedalConfigError } from '../medal';\nimport type { BookingRuntime } from '../runtime';\n\nexport function bookingErrorResponse(code: string, status: number, message?: string): NextResponse {\n return NextResponse.json(message === undefined ? { error: code } : { error: code, message }, {\n status,\n });\n}\n\n/**\n * The catch for the read routes, in one place because it is one behaviour.\n *\n * The upstream *status* is relayed rather than flattened, because it is the\n * part that says what to do — a 404 means this service no longer exists and\n * the wizard should reload the catalogue, a 429 means back off, a 403 means\n * the plan lost the bookings module. The upstream *message* is not.\n *\n * A `MedalApiError` already carries its own explanation on both sides of the\n * wire; a missing API key and an unrecognised throw do not, and those two are\n * logged.\n */\nexport function catalogueErrorResponse(\n rt: Pick<BookingRuntime, 'logger'>,\n error: unknown,\n endpoint: string\n): NextResponse {\n if (error instanceof MedalConfigError) {\n rt.logger.error({ err: error, endpoint }, 'Booking API is not configured');\n return bookingErrorResponse('unconfigured', 503);\n }\n if (error instanceof MedalApiError) {\n return bookingErrorResponse(error.code ?? 'upstreamError', error.status);\n }\n rt.logger.error({ err: error, endpoint }, 'Unexpected booking API failure');\n return bookingErrorResponse('upstreamError', 502);\n}\n\n/** What a service id looks like: Medal's ids are word characters and dashes. */\nconst SERVICE_ID_SHAPE = /^[\\w-]{1,64}$/;\n\n/**\n * A `service_id` from the query string, checked before it can reach a cache\n * key or a cache tag: the right shape, and a service the catalogue actually\n * has. Otherwise every invented id would be a Medal call and a cache entry.\n *\n * The catalogue read is the cached one, so a real id costs nothing extra.\n */\nexport async function knownServiceId(\n rt: Pick<BookingRuntime, 'logger' | 'catalogue'>,\n raw: string\n): Promise<{ serviceId: string } | { error: NextResponse }> {\n if (!SERVICE_ID_SHAPE.test(raw)) {\n return { error: bookingErrorResponse('invalidInput', 400, 'service_id is malformed') };\n }\n let services: Array<{ id: string }>;\n try {\n services = await rt.catalogue.cachedServices();\n } catch (error) {\n return { error: catalogueErrorResponse(rt, error, 'services') };\n }\n if (!services.some((service) => service.id === raw)) {\n return { error: bookingErrorResponse('invalidInput', 400, 'Unknown service_id') };\n }\n return { serviceId: raw };\n}\n\n/**\n * How many services one person's visit may add after the first — the engine's\n * own bound (four services in all), refused here so a visitor's query string\n * cannot make it a Medal call.\n */\nexport const MAX_EXTRA_SERVICES = 3;\n\n/**\n * `extra_service_ids` from the query string — the rest of ONE person's visit\n * after `service_id`, comma-separated, in order. Blank entries are dropped, so\n * an absent, empty or `,`-only value is a one-service visit (`[]`).\n *\n * Held to everything the engine would refuse with a 422 (more than three, one\n * twice, `service_id` again) and to what `knownServiceId` holds `service_id` to\n * (the shape, and a service the catalogue has), for the same reason: every id\n * here lands in a cache key and a cache tag. Call it after `knownServiceId`.\n */\nexport async function knownExtraServiceIds(\n rt: Pick<BookingRuntime, 'logger' | 'catalogue'>,\n raw: string | null,\n serviceId: string\n): Promise<{ extraServiceIds: string[] } | { error: NextResponse }> {\n const ids = (raw ?? '')\n .split(',')\n .map((id) => id.trim())\n .filter((id) => id.length > 0);\n if (ids.length === 0) return { extraServiceIds: [] };\n const refuse = (message: string) => ({\n error: bookingErrorResponse('invalidInput', 400, message),\n });\n if (ids.length > MAX_EXTRA_SERVICES) {\n return refuse(`extra_service_ids must name at most ${MAX_EXTRA_SERVICES} services`);\n }\n if (new Set(ids).size !== ids.length) return refuse('extra_service_ids names a service twice');\n if (ids.includes(serviceId)) return refuse('extra_service_ids repeats service_id');\n if (!ids.every((id) => SERVICE_ID_SHAPE.test(id))) {\n return refuse('extra_service_ids is malformed');\n }\n let services: Array<{ id: string }>;\n try {\n services = await rt.catalogue.cachedServices();\n } catch (error) {\n return { error: catalogueErrorResponse(rt, error, 'services') };\n }\n const known = new Set(services.map((service) => service.id));\n if (!ids.every((id) => known.has(id))) return refuse('Unknown service in extra_service_ids');\n return { extraServiceIds: ids };\n}\n\n/**\n * The engine's «that slot is no longer free».\n *\n * It arrives as `CONFLICT` with `SLOT_TAKEN` in the message: the SDK exposes no\n * finer code (`MedalApiError.code` is the generic `CONFLICT`, shared with a\n * duplicate key and an unseatable party). The message is read HERE and nowhere\n * else, so the day the engine grows a structured code there is one line to\n * change.\n */\nexport function isSlotTaken(error: unknown): boolean {\n return (\n error instanceof MedalApiError &&\n error.code === 'CONFLICT' &&\n error.message.includes('SLOT_TAKEN')\n );\n}\n\n/**\n * 62 days. A bound on what a visitor-controlled query string may ask the site\n * to fetch on their behalf — not the engine's own limit, which clamps rather\n * than refuses. The wizard asks for a week at a time.\n */\nconst MAX_RANGE_MS = 62 * 24 * 60 * 60 * 1000;\n\n/**\n * Epoch milliseconds or an ISO 8601 string — the same two forms the engine's\n * own parser accepts, so a bound this site admits is one Medal would have\n * admitted too. `null` for anything it cannot read as a time.\n */\nfunction parseTimestamp(raw: string | null): number | null {\n const trimmed = raw?.trim();\n if (!trimmed) return null;\n const numeric = Number(trimmed);\n const timestamp = Number.isFinite(numeric) ? numeric : Date.parse(trimmed);\n return Number.isFinite(timestamp) ? timestamp : null;\n}\n\n/**\n * The `from_ts`/`to_ts` pair the read routes take, validated before Medal is\n * called. Every guard answers a request the engine would also refuse, and an\n * unreadable bound would otherwise throw inside the client and surface as an\n * opaque 502 pointing at Medal for a typo in our own query string.\n */\nexport function parseRange(\n searchParams: URLSearchParams\n): { fromTs: number; toTs: number } | { error: NextResponse } {\n const fromTs = parseTimestamp(searchParams.get('from_ts'));\n const toTs = parseTimestamp(searchParams.get('to_ts'));\n if (fromTs === null || toTs === null) {\n return {\n error: bookingErrorResponse(\n 'invalidInput',\n 400,\n 'from_ts and to_ts must be Unix milliseconds or ISO 8601 date-times'\n ),\n };\n }\n if (toTs <= fromTs) {\n return { error: bookingErrorResponse('invalidInput', 400, 'to_ts must be after from_ts') };\n }\n if (toTs - fromTs > MAX_RANGE_MS) {\n return { error: bookingErrorResponse('invalidInput', 400, 'Range must not exceed 62 days') };\n }\n return { fromTs, toTs };\n}\n\n/** SHA-256, hex — for idempotency keys that must never carry what they hash. */\nexport async function digestOf(input: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));\n return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"shared.mjs","names":[],"sources":["../../../src/next/routes/shared.ts"],"sourcesContent":["/**\n * What the booking routes are allowed to say to a browser.\n *\n * The wizard reads `error` straight into `WizardState.error`, which is a closed\n * union — `'maxParty' | 'slotTaken' | 'conflict' | 'invalidInput' |\n * 'unconfigured' | 'upstreamError'` — so the spelling here is load-bearing\n * rather than descriptive.\n *\n * The create route also answers `'inProgress'` (409, a key Medal already holds)\n * and `'accountRequired'` (401, `account.required` with nobody logged in).\n *\n * `upstreamError` is the catch-all. Something has to be in the field either\n * way, because `NextResponse.json({ error: undefined })` serialises to `{}`\n * and reads as a failure with no reason at all.\n *\n * Only the create route is held to the union. `catalogueErrorResponse` also\n * relays the upstream's own code where there is one, so the read routes can\n * say words the wizard has never heard of — which is safe precisely because\n * nothing is reading them into `WizardState`.\n *\n * `message` appears only when THIS file wrote it. Medal's own error bodies are\n * written for the integrator holding the API key — they name workspaces, ids\n * and internal field paths — and none of that belongs in front of a visitor.\n */\n\nimport { MedalApiError } from '@medalsocial/sdk';\nimport { NextResponse } from 'next/server';\nimport { MAX_RANGE_DAYS, MAX_RANGE_MS } from '../../core/range';\nimport { MedalConfigError } from '../medal';\nimport type { BookingRuntime } from '../runtime';\n\nexport function bookingErrorResponse(code: string, status: number, message?: string): NextResponse {\n return NextResponse.json(message === undefined ? { error: code } : { error: code, message }, {\n status,\n });\n}\n\n/**\n * The catch for the read routes, in one place because it is one behaviour.\n *\n * The upstream *status* is relayed rather than flattened, because it is the\n * part that says what to do — a 404 means this service no longer exists and\n * the wizard should reload the catalogue, a 429 means back off, a 403 means\n * the plan lost the bookings module. The upstream *message* is not.\n *\n * A `MedalApiError` already carries its own explanation on both sides of the\n * wire; a missing API key and an unrecognised throw do not, and those two are\n * logged.\n */\nexport function catalogueErrorResponse(\n rt: Pick<BookingRuntime, 'logger'>,\n error: unknown,\n endpoint: string\n): NextResponse {\n if (error instanceof MedalConfigError) {\n rt.logger.error({ err: error, endpoint }, 'Booking API is not configured');\n return bookingErrorResponse('unconfigured', 503);\n }\n if (error instanceof MedalApiError) {\n return bookingErrorResponse(error.code ?? 'upstreamError', error.status);\n }\n rt.logger.error({ err: error, endpoint }, 'Unexpected booking API failure');\n return bookingErrorResponse('upstreamError', 502);\n}\n\n/** What a service id looks like: Medal's ids are word characters and dashes. */\nconst SERVICE_ID_SHAPE = /^[\\w-]{1,64}$/;\n\n/**\n * A `service_id` from the query string, checked before it can reach a cache\n * key or a cache tag: the right shape, and a service the catalogue actually\n * has. Otherwise every invented id would be a Medal call and a cache entry.\n *\n * The catalogue read is the cached one, so a real id costs nothing extra.\n */\nexport async function knownServiceId(\n rt: Pick<BookingRuntime, 'logger' | 'catalogue'>,\n raw: string\n): Promise<{ serviceId: string } | { error: NextResponse }> {\n if (!SERVICE_ID_SHAPE.test(raw)) {\n return { error: bookingErrorResponse('invalidInput', 400, 'service_id is malformed') };\n }\n let services: Array<{ id: string }>;\n try {\n services = await rt.catalogue.cachedServices();\n } catch (error) {\n return { error: catalogueErrorResponse(rt, error, 'services') };\n }\n if (!services.some((service) => service.id === raw)) {\n return { error: bookingErrorResponse('invalidInput', 400, 'Unknown service_id') };\n }\n return { serviceId: raw };\n}\n\n/**\n * How many services one person's visit may add after the first — the engine's\n * own bound (four services in all), refused here so a visitor's query string\n * cannot make it a Medal call.\n */\nexport const MAX_EXTRA_SERVICES = 3;\n\n/**\n * `extra_service_ids` from the query string — the rest of ONE person's visit\n * after `service_id`, comma-separated, in order. Blank entries are dropped, so\n * an absent, empty or `,`-only value is a one-service visit (`[]`).\n *\n * Held to everything the engine would refuse with a 422 (more than three, one\n * twice, `service_id` again) and to what `knownServiceId` holds `service_id` to\n * (the shape, and a service the catalogue has), for the same reason: every id\n * here lands in a cache key and a cache tag. Call it after `knownServiceId`.\n */\nexport async function knownExtraServiceIds(\n rt: Pick<BookingRuntime, 'logger' | 'catalogue'>,\n raw: string | null,\n serviceId: string\n): Promise<{ extraServiceIds: string[] } | { error: NextResponse }> {\n const ids = (raw ?? '')\n .split(',')\n .map((id) => id.trim())\n .filter((id) => id.length > 0);\n if (ids.length === 0) return { extraServiceIds: [] };\n const refuse = (message: string) => ({\n error: bookingErrorResponse('invalidInput', 400, message),\n });\n if (ids.length > MAX_EXTRA_SERVICES) {\n return refuse(`extra_service_ids must name at most ${MAX_EXTRA_SERVICES} services`);\n }\n if (new Set(ids).size !== ids.length) return refuse('extra_service_ids names a service twice');\n if (ids.includes(serviceId)) return refuse('extra_service_ids repeats service_id');\n if (!ids.every((id) => SERVICE_ID_SHAPE.test(id))) {\n return refuse('extra_service_ids is malformed');\n }\n let services: Array<{ id: string }>;\n try {\n services = await rt.catalogue.cachedServices();\n } catch (error) {\n return { error: catalogueErrorResponse(rt, error, 'services') };\n }\n const known = new Set(services.map((service) => service.id));\n if (!ids.every((id) => known.has(id))) return refuse('Unknown service in extra_service_ids');\n return { extraServiceIds: ids };\n}\n\n/**\n * The engine's refusals that all mean «that time cannot be booked — pick\n * another»: taken, already passed, beyond the booking horizon, off the slot\n * grid. Each is answered the same way — fresh openings, back to the time step —\n * because the visitor's only move is a different time. Read as anything else,\n * the wizard said «Prøv en gang til» and kept the hour, and every retry was\n * refused again: a tab left open overnight offers hours that have passed.\n *\n * The API answers all four with HTTP 409 and code `CONFLICT`, the reason in\n * `error.details.reason` (lower case) and the same word upper-cased as the\n * message prefix (`IN_PAST: …`). Read defensively anyway — any 409 whatever\n * its code, the reason in any case, the message prefix when there is no\n * reason — so a change of spelling upstream degrades to «pick another time»,\n * never to a retry loop. Only a 409 is ever one: a 400 or 422 is a request\n * the visitor's next tap cannot fix by picking a time.\n */\nconst PICK_ANOTHER_TIME = new Set(['slot_taken', 'in_past', 'outside_horizon', 'off_grid']);\n\n/** The same refusals by their message prefix, for an engine that sends no `details.reason`. */\nconst PICK_ANOTHER_TIME_MESSAGE = /\\b(?:SLOT_TAKEN|IN_PAST|OUTSIDE_HORIZON|OFF_GRID)\\b/;\n\n/** `error.details.reason` in lower case — the engine's structured refusal, when it sent one. */\nfunction refusalReason(error: MedalApiError): string | null {\n const details = error.details;\n if (typeof details !== 'object' || details === null || !('reason' in details)) return null;\n return typeof details.reason === 'string' ? details.reason.toLowerCase() : null;\n}\n\n/**\n * The engine's «that time is no longer bookable» (see `PICK_ANOTHER_TIME`).\n *\n * It arrives as a 409, shared with a duplicate key and an unseatable party. The reason is read HERE and nowhere else: structured when\n * the engine sends one, else the message prefix it has always carried.\n */\nexport function isSlotTaken(error: unknown): boolean {\n if (!(error instanceof MedalApiError) || error.status !== 409) return false;\n const reason = refusalReason(error);\n if (reason !== null) return PICK_ANOTHER_TIME.has(reason);\n return PICK_ANOTHER_TIME_MESSAGE.test(error.message);\n}\n\n/**\n * Epoch milliseconds or an ISO 8601 string — the same two forms the engine's\n * own parser accepts, so a bound this site admits is one Medal would have\n * admitted too. `null` for anything it cannot read as a time.\n */\nfunction parseTimestamp(raw: string | null): number | null {\n const trimmed = raw?.trim();\n if (!trimmed) return null;\n const numeric = Number(trimmed);\n const timestamp = Number.isFinite(numeric) ? numeric : Date.parse(trimmed);\n return Number.isFinite(timestamp) ? timestamp : null;\n}\n\n/**\n * The `from_ts`/`to_ts` pair the read routes take, validated before Medal is\n * called. Every guard answers a request the engine would also refuse, and an\n * unreadable bound would otherwise throw inside the client and surface as an\n * opaque 502 pointing at Medal for a typo in our own query string.\n */\nexport function parseRange(\n searchParams: URLSearchParams\n): { fromTs: number; toTs: number } | { error: NextResponse } {\n const fromTs = parseTimestamp(searchParams.get('from_ts'));\n const toTs = parseTimestamp(searchParams.get('to_ts'));\n if (fromTs === null || toTs === null) {\n return {\n error: bookingErrorResponse(\n 'invalidInput',\n 400,\n 'from_ts and to_ts must be Unix milliseconds or ISO 8601 date-times'\n ),\n };\n }\n if (toTs <= fromTs) {\n return { error: bookingErrorResponse('invalidInput', 400, 'to_ts must be after from_ts') };\n }\n // A bound on what a visitor-controlled query string may ask the site to\n // fetch on their behalf — not the engine's own limit, which clamps rather\n // than refuses. It admits the widest window a valid config can produce,\n // DST hour included (`core/range.ts`).\n if (toTs - fromTs > MAX_RANGE_MS) {\n return {\n error: bookingErrorResponse(\n 'invalidInput',\n 400,\n `Range must not exceed ${MAX_RANGE_DAYS} days`\n ),\n };\n }\n return { fromTs, toTs };\n}\n\n/**\n * Expire the slot cache and this colo's booking seeds for services just\n * booked, moved or cancelled.\n *\n * Never throws. The write has already happened when this runs, and a cache\n * that could not be told must not turn it into an error: the visitor would be\n * told a booking failed that exists, and might book it again. The worst a\n * failure here costs is a stale opening for a few minutes, which Medal's own\n * check at the next submit refuses.\n */\nexport async function forgetBookedSlots(\n rt: BookingRuntime,\n serviceIds: readonly string[]\n): Promise<void> {\n try {\n rt.catalogue.expireSlots([...serviceIds]);\n await rt.seed.expireBookingSeeds([...serviceIds]);\n } catch (error) {\n rt.logger.error({ err: error }, 'Could not expire the booking caches after a write');\n }\n}\n\n/** SHA-256, hex — for idempotency keys that must never carry what they hash. */\nexport async function digestOf(input: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));\n return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');\n}\n"],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  import { WizardState, WizardStep } from "../core/machine.mjs";
2
2
  import { PortalActions } from "./actions.mjs";
3
3
  import { BookingOverrides } from "./Provider.mjs";
4
- import { BookingSeed, useBooking } from "./useBooking.mjs";
4
+ import { BookingReadEndpoint, BookingSeed, useBooking } from "./useBooking.mjs";
5
5
  //#region src/react/BookingWizard.d.ts
6
6
  export interface BookingWizardProps extends BookingOverrides {
7
7
  /** What the page prefetched (the `/next` loader's seed). */
@@ -38,6 +38,12 @@ export type BookingWizardEvent = {
38
38
  } | {
39
39
  type: 'submit_error';
40
40
  code: NonNullable<WizardState['error']>;
41
+ } |
42
+ /** A read the time step waits on failed: `status` is `null` when nothing answered. */
43
+ {
44
+ type: 'read_error';
45
+ endpoint: BookingReadEndpoint;
46
+ status: number | null;
41
47
  };
42
48
  /**
43
49
  * The wizard, behind its own Suspense boundary: `useSearchParams` throws to
@@ -159,6 +159,19 @@ function BookingWizardShell(props) {
159
159
  });
160
160
  lastError.current = state.error;
161
161
  }, [state.error, onEvent]);
162
+ const { readErrors } = booking;
163
+ const lastReadSeq = useRef(0);
164
+ useEffect(() => {
165
+ for (const failure of readErrors) {
166
+ if (failure.seq <= lastReadSeq.current) continue;
167
+ lastReadSeq.current = failure.seq;
168
+ onEvent?.({
169
+ type: "read_error",
170
+ endpoint: failure.endpoint,
171
+ status: failure.status
172
+ });
173
+ }
174
+ }, [readErrors, onEvent]);
162
175
  const confirmedCount = confirmed?.bookings.length ?? 0;
163
176
  useEffect(() => {
164
177
  if (confirmedCount > 0) onEvent?.({
@@ -1 +1 @@
1
- {"version":3,"file":"BookingWizard.mjs","names":["LoginSheet"],"sources":["../../src/react/BookingWizard.tsx"],"sourcesContent":["/**\n * `<BookingWizard>` — the booking flow, from «who is it for?» to the\n * confirmation, drawn with the `@medalsocial/meda/booking` screens over\n * `useBooking()`.\n *\n * The hook owns the machine, the fetches and the stores; this file owns the\n * page: which step is on screen (ONE at a time — the details step's nonce and\n * the submit flag depend on it), focus and scroll on every move, the restore\n * gate's pre-hydration script and skeleton, the header, and what each screen\n * is handed (prices, the weekend note, ages, the possessive, the calendar\n * file) out of the site's config.\n */\n\nimport {\n BookingButton,\n BookingRecap,\n Confirmation,\n DetailsScreen,\n LiveStatus,\n ServiceScreen,\n StylistScreen,\n SummaryBar,\n TakenToast,\n TimeScreen,\n TimeScreenSkeleton,\n WhoScreen,\n} from '@medalsocial/meda/booking';\nimport { ArrowLeft } from 'lucide-react';\nimport {\n type ReactNode,\n Suspense,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n} from 'react';\nimport { fill, fillParts, labelText } from '../core/labels';\nimport { SELF_KEY, type WizardPerson, type WizardState, type WizardStep } from '../core/machine';\nimport type { BookingServiceDto } from '../core/types';\nimport { visitServicesOf } from '../core/visit';\nimport type { PortalActions } from './actions';\nimport type { BookingKit } from './kit';\nimport { LoginSheet } from './LoginSheet';\nimport type { BookingOverrides, ResolvedBooking } from './Provider';\nimport { useBookingKit } from './Provider';\nimport { screenLabels } from './screen-labels';\nimport {\n type BookingConfirmation,\n type BookingController,\n type BookingSeed,\n useBooking,\n} from './useBooking';\nimport {\n addedChildEntries,\n confirmationProps,\n detailsLines,\n detailsScreenBase,\n familyEntries,\n GUEST_ADULT_SEAT,\n guestChoices,\n partyPeople,\n partySizeWord,\n recapProps,\n serviceFitsFor,\n serviceScreenBase,\n stylistFace,\n weekendNoteFor,\n} from './wizard/adapters';\nimport { useWallClock } from './wizard/wall-clock';\nimport { forMedaScreen, wizardErrorText } from './wizard-error';\n\nexport interface BookingWizardProps extends BookingOverrides {\n /** What the page prefetched (the `/next` loader's seed). */\n seed: BookingSeed;\n /** This request's phone and address, over `config.contact` (the loader's `contact`). */\n contact?: { phone?: string | null; address?: string | null };\n /** The parent the page found a portal session for; `null` for a guest. */\n guardian?: BookingGuardianProp;\n /**\n * The app's server actions for the login sheet (Decision 8). Without them —\n * or with `config.portal.enabled` off — no login is offered.\n */\n actions?: Pick<PortalActions, 'startLogin' | 'startVipps'>;\n /** How many days the window covers. Default `config.window.rangeDays`. */\n rangeDays?: number;\n /**\n * The site's origin (`https://…`), so the manage links inside the calendar\n * file are absolute. Absent, they stay root-relative.\n */\n siteUrl?: string;\n /** A monitoring tap: the step the visitor is on, and a submission's outcome. */\n onEvent?: (event: BookingWizardEvent) => void;\n}\n\ntype BookingGuardianProp = Parameters<typeof useBooking>[0]['guardian'];\n\nexport type BookingWizardEvent =\n | { type: 'step'; step: WizardStep | 'confirmed' }\n | { type: 'submit_ok'; bookings: number }\n | { type: 'submit_error'; code: NonNullable<WizardState['error']> };\n\n/** The bundler's build mode, which it inlines; absent outside a bundler. */\ndeclare const process: { env: { NODE_ENV?: string } } | undefined;\n\n/** The wizard's root, for the inline restore gate to find before hydration. */\nconst WIZARD_ROOT_ID = 'booking-wizard';\n\n/** The time step's heading, which a lost slot sends the visitor back to. */\nconst TIME_HEADING_ID = 'booking-time-heading';\n\n/** The heading each screen focuses when the visitor arrives on it. */\nconst STEP_HEADINGS: Record<WizardStep | 'confirmed', string> = {\n who: 'booking-who-heading',\n service: 'booking-service-heading',\n when: 'booking-stylist-heading',\n details: 'booking-details-heading',\n confirmed: 'booking-confirmed-heading',\n};\n\nconst STEPS: readonly WizardStep[] = ['who', 'service', 'when', 'details'];\n\n/** Smooth, or instant for a visitor who asked for reduced motion. */\nfunction scrollToTop(target: HTMLElement | null): void {\n if (!target || typeof target.scrollIntoView !== 'function') return;\n const reduced =\n typeof window.matchMedia === 'function' &&\n window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n target.scrollIntoView({ behavior: reduced ? 'auto' : 'smooth', block: 'start' });\n}\n\n/**\n * The wizard, behind its own Suspense boundary: `useSearchParams` throws to\n * the nearest one during a static prerender, and without ours that is the\n * root layout's — the whole page would ship empty until hydration.\n */\nexport function BookingWizard(props: BookingWizardProps) {\n return (\n <Suspense fallback={null}>\n <BookingWizardShell {...props} />\n </Suspense>\n );\n}\n\nfunction BookingWizardShell(props: BookingWizardProps) {\n const resolved = useBookingKit(props, props.contact);\n const loginOffered = props.actions !== undefined && resolved.kit.config.portal.enabled;\n const booking = useBooking({ ...props, loginOffered });\n const { kit } = booking;\n const { labels } = kit;\n\n // `account.required` with no way to log in: every «Bekreft» would be refused.\n const unreachableLogin = kit.config.account.required && !loginOffered;\n useEffect(() => {\n if (\n unreachableLogin &&\n typeof process !== 'undefined' &&\n process.env.NODE_ENV !== 'production'\n ) {\n console.warn('[booking] account.required without login actions: nobody can log in to book.');\n }\n }, [unreachableLogin]);\n const { state, restore, confirmed, onEvent } = { ...booking, onEvent: props.onEvent };\n\n /**\n * On every change of screen: the wizard's top into view and focus on the\n * new step's heading. Never on the first screen, and never for what the\n * restore gate reveals — that is the page arriving, not the visitor moving.\n */\n const rootRef = useRef<HTMLDivElement>(null);\n const screenKey: WizardStep | 'confirmed' = confirmed !== null ? 'confirmed' : state.step;\n const shownScreen = useRef<WizardStep | 'confirmed' | null>(null);\n const takenSlotTs = booking.slots.takenSlotTs;\n const timeStepReady = booking.slots.ready;\n const focusTimeWhenReady = useRef(false);\n useEffect(() => {\n if (restore.restoring) return;\n const previous = shownScreen.current;\n shownScreen.current = screenKey;\n if (previous === screenKey) return;\n onEvent?.({ type: 'step', step: screenKey });\n if (previous === null) return;\n // A lost slot sends the visitor back for the ALTERNATIVES, on the time half.\n const toTimes = screenKey === 'when' && takenSlotTs !== null;\n const headingId = toTimes ? TIME_HEADING_ID : STEP_HEADINGS[screenKey];\n const heading = document.getElementById(headingId);\n scrollToTop(toTimes ? heading : rootRef.current);\n heading?.focus({ preventScroll: true });\n // The heading focused now may be the skeleton's; focus follows the real one.\n focusTimeWhenReady.current = toTimes && !timeStepReady;\n }, [screenKey, restore.restoring, takenSlotTs, timeStepReady, onEvent]);\n\n /** After a login from the sheet the row that opened it is gone: focus the step's heading. */\n const focusAfterSignIn = useRef(false);\n const signedIn = booking.login.signedIn;\n useEffect(() => {\n if (signedIn === null || !focusAfterSignIn.current) return;\n focusAfterSignIn.current = false;\n document.getElementById(STEP_HEADINGS[state.step])?.focus({ preventScroll: true });\n }, [signedIn, state.step]);\n /**\n * A 401 put the gate back over the form: focus its heading (the details\n * step's id), where the notice beside it says why. Once per loss, and only\n * once the gate is actually on screen — a replay answers behind the skeleton.\n */\n const sessionLost = booking.login.sessionLost;\n const sessionLossFocused = useRef(false);\n useEffect(() => {\n if (!sessionLost) {\n sessionLossFocused.current = false;\n return;\n }\n if (sessionLossFocused.current || restore.restoring || state.step !== 'details') return;\n sessionLossFocused.current = true;\n const heading = document.getElementById(STEP_HEADINGS.details);\n scrollToTop(rootRef.current);\n heading?.focus({ preventScroll: true });\n }, [sessionLost, restore.restoring, state.step]);\n useEffect(() => {\n if (!timeStepReady || !focusTimeWhenReady.current) return;\n focusTimeWhenReady.current = false;\n const active = document.activeElement;\n if (active === null || active === document.body) {\n document.getElementById(TIME_HEADING_ID)?.focus({ preventScroll: true });\n }\n }, [timeStepReady]);\n\n // The monitoring tap for a submission's outcome.\n const lastError = useRef(state.error);\n useEffect(() => {\n if (state.error !== null && state.error !== lastError.current) {\n onEvent?.({ type: 'submit_error', code: state.error });\n }\n lastError.current = state.error;\n }, [state.error, onEvent]);\n const confirmedCount = confirmed?.bookings.length ?? 0;\n useEffect(() => {\n if (confirmedCount > 0) onEvent?.({ type: 'submit_ok', bookings: confirmedCount });\n }, [confirmedCount, onEvent]);\n\n // React owns the root from here on: drop the pre-hydration mark the inline\n // script may have set, and say so, so the script's fallback timer stands down.\n useLayoutEffect(() => {\n if (!restore.hydrated) return;\n rootRef.current?.removeAttribute('data-restoring');\n rootRef.current?.setAttribute('data-hydrated', '');\n }, [restore.hydrated]);\n\n // Steps animate in only once the visitor has moved between them.\n const [arrivalStep, setArrivalStep] = useState<WizardStep | null>(\n restore.restoring ? null : state.step\n );\n if (!restore.restoring && arrivalStep === null) setArrivalStep(state.step);\n const [stepsMovedOnce, setStepsMovedOnce] = useState(false);\n const movedNow = arrivalStep !== null && !restore.restoring && state.step !== arrivalStep;\n if (movedNow && !stepsMovedOnce) setStepsMovedOnce(true);\n const stepsMoved = stepsMovedOnce || movedNow;\n\n const restoreSkeleton = <RestoreSkeleton text={labelText(labels['wizard.restoring'])} />;\n /**\n * The root both screens share: the scroll target, and the element the\n * inline restore gate marks. The script and the hidden skeleton exist only\n * in the server's HTML and the hydration pass that adopts it.\n */\n const shell = (content: ReactNode) => (\n <div\n id={WIZARD_ROOT_ID}\n ref={rootRef}\n className={resolved.classNames.wizard?.root ?? 'group scroll-mt-28'}\n suppressHydrationWarning\n >\n {!restore.hydrated && (\n <>\n <script\n // A fixed script built from constants, run before hydration.\n // biome-ignore lint/security/noDangerouslySetInnerHtml: the restore gate must run before React does.\n dangerouslySetInnerHTML={{\n __html: kit.restoreGate.restoreGateScript(\n WIZARD_ROOT_ID,\n restore.linked,\n restore.resuming\n ),\n }}\n />\n <div className=\"hidden group-data-[restoring]:block\">{restoreSkeleton}</div>\n </>\n )}\n {restore.restoring && restore.hydrated ? (\n restoreSkeleton\n ) : (\n <div className=\"group-data-[restoring]:hidden\">{content}</div>\n )}\n </div>\n );\n\n if (confirmed !== null) {\n return shell(\n <ConfirmationStep booking={booking} resolved={resolved} siteUrl={props.siteUrl} />\n );\n }\n\n const onSignedIn = (who: Parameters<BookingController['login']['signIn']>[0]) => {\n focusAfterSignIn.current = true;\n booking.login.signIn(who);\n };\n // The multi-select service step carries its own total bar and refusal notice.\n const multiSelect = state.step === 'service' && kit.config.party.maxServicesPerPerson > 1;\n /**\n * `account.required`: every booking is a logged-in parent's. The login is\n * not offered along the way — it IS «Bekreft», for a parent not logged in.\n */\n const accountRequired = kit.config.account.required && loginOffered;\n const gated = accountRequired && state.step === 'details' && !booking.login.loggedIn;\n const loginRow =\n loginOffered && !accountRequired ? (\n <LoginRow\n booking={booking}\n actions={props.actions as NonNullable<BookingWizardProps['actions']>}\n overrides={props}\n onSignedIn={onSignedIn}\n />\n ) : null;\n\n return shell(\n <div className=\"space-y-8\">\n <WizardHeader\n kit={kit}\n state={state}\n submitting={booking.submitting}\n onGo={(step) => booking.dispatch({ type: 'goToStep', step })}\n />\n\n {/* The machine's error, on the steps that have nowhere else to put it. */}\n {state.error !== null &&\n state.step !== 'details' &&\n state.step !== 'when' &&\n !multiSelect && (\n <p\n role=\"alert\"\n className={\n resolved.classNames.wizard?.alert ??\n 'rounded-lg border border-destructive/40 bg-destructive/10 px-5 py-3 text-sm'\n }\n >\n {wizardErrorText(labels, state.error, kit.config.party.maxServicesPerPerson)}\n </p>\n )}\n\n {/* What is being booked, first: above the login offer and the form. */}\n {state.step === 'details' && !gated && kit.config.screens.recap && (\n <RecapStep booking={booking} resolved={resolved} />\n )}\n\n {state.step === 'details' && loginRow}\n\n {/* The gate takes a Vipps confirm code itself, in place of the form. */}\n {loginOffered && booking.login.vippsConfirm !== null && signedIn === null && !gated && (\n <LoginSheet\n {...overridesOf(props)}\n actions={props.actions as NonNullable<BookingWizardProps['actions']>}\n presentation=\"sheet\"\n trigger={false}\n vippsConfirm={booking.login.vippsConfirm}\n resumePath={booking.login.resumePath}\n onSignedIn={onSignedIn}\n />\n )}\n\n {/* One box per step, keyed by it, so a step arriving is a mount. */}\n <div\n key={state.step}\n data-booking-step={state.step}\n data-animate={stepsMoved ? '' : undefined}\n className=\"space-y-8\"\n >\n {state.step === 'who' && (\n <WhoStep booking={booking} resolved={resolved} loginRow={loginRow} />\n )}\n {state.step === 'service' && (\n <ServiceStep booking={booking} resolved={resolved} multiSelect={multiSelect} />\n )}\n {state.step === 'when' && <WhenStep booking={booking} resolved={resolved} />}\n {state.step === 'details' &&\n (gated ? (\n <AccountGate\n booking={booking}\n resolved={resolved}\n actions={props.actions as NonNullable<BookingWizardProps['actions']>}\n overrides={props}\n onSignedIn={onSignedIn}\n />\n ) : (\n <DetailsStep booking={booking} resolved={resolved} />\n ))}\n </div>\n\n {!multiSelect && (\n <SummaryBar\n {...(kit.config.screens.summaryDetail\n ? booking.derived.summaryParts\n : { line: booking.derived.summary })}\n canAdvance={booking.derived.canAdvance}\n step={state.step}\n onNext={booking.next}\n hideNextWhenDisabled={kit.config.screens.hideDisabledNext}\n labels={screenLabels(labels)}\n classNames={resolved.classNames.summary}\n />\n )}\n </div>\n );\n}\n\n/** The overrides a nested booking component inherits from the wizard's props. */\nfunction overridesOf(props: BookingOverrides): BookingOverrides {\n return {\n config: props.config,\n labels: props.labels,\n classNames: props.classNames,\n components: props.components,\n };\n}\n\ninterface StepProps {\n booking: BookingController;\n resolved: ResolvedBooking;\n}\n\nfunction WhoStep({ booking, resolved, loginRow }: StepProps & { loginRow: ReactNode }) {\n const { kit, state, people } = booking;\n const guests = useMemo(() => guestChoices(kit), [kit]);\n return (\n <WhoScreen\n labels={screenLabels(kit.labels)}\n format={kit.format}\n people={state.people}\n family={people.family === null ? null : familyEntries(kit, people.family, people.ageDayKey)}\n guestChoices={guests}\n addedChildren={addedChildEntries(kit, state.people, people.ageDayKey)}\n maxPeople={kit.wizard.maxPeople}\n selfKey={SELF_KEY}\n isGuestSeat={kit.wizard.isGuestSeat}\n onChoose={people.choosePeople}\n onAddChild={people.addChild}\n guestParty={\n kit.config.screens.guestParty\n ? { child: kit.wizard.guestChild, adult: GUEST_ADULT_SEAT }\n : undefined\n }\n loginRow={loginRow}\n currentYear={Number(kit.clock.dayKey(booking.slots.fromTs).slice(0, 4))}\n classNames={resolved.classNames.who}\n components={\n resolved.components.PersonCard ? { PersonCard: resolved.components.PersonCard } : undefined\n }\n />\n );\n}\n\nfunction ServiceStep({ booking, resolved, multiSelect }: StepProps & { multiSelect: boolean }) {\n const { kit, state, people, catalogue } = booking;\n const ageOf = (person: WizardPerson) => people.personAge(person, people.ageDayKey);\n const single = state.people.length === 1 ? state.people[0] : null;\n // «Samme som sist» stays a one-tap shortcut while the person's visit is\n // nothing, or just that service (a returning child is seated with it already\n // ticked). Once they have ticked something else, a one-tap pick would REPLACE\n // their visit — and, alone, jump to the time step — so the suggestion joins\n // the visit instead, and never unticks a service already in it.\n const pickFor = (index: number, service: BookingServiceDto, oneTap: () => void) => {\n const visit = state.choices[index] ? [state.choices[index], ...state.extras[index]] : [];\n if (!multiSelect || visit.length === 0 || (visit.length === 1 && visit[0].id === service.id)) {\n oneTap();\n } else if (!visit.some((ticked) => ticked.id === service.id)) {\n booking.toggleServiceFor(index, service);\n }\n };\n return (\n <ServiceScreen\n labels={screenLabels(kit.labels)}\n format={kit.format}\n services={catalogue.services}\n {...serviceScreenBase(kit)}\n serviceFits={serviceFitsFor(kit, state.people.map(ageOf), state.people)}\n initialCategory={catalogue.deepLinkCategory}\n onPick={(service) => pickFor(0, service, () => booking.pickService(service))}\n suggestion={single ? people.suggestionFor(single) : null}\n chosenId={state.choices[0]?.id ?? null}\n childName={single ? (single.name ?? null) : null}\n party={\n state.people.length > 1\n ? {\n people: partyPeople(kit, state.people, ageOf, people.suggestionFor),\n choices: state.choices,\n onPickFor: (index, service) =>\n pickFor(index, service, () => booking.pickServiceFor(index, service)),\n onRemove: (index) =>\n booking.dispatch({\n type: 'choosePeople',\n people: state.people.filter((_, position) => position !== index),\n }),\n }\n : undefined\n }\n selection={\n multiSelect\n ? {\n // Index = seated person; meda reads a missing list (nobody seated) as empty.\n lists: state.choices.map((first, index) =>\n first ? [first, ...state.extras[index]] : []\n ),\n onToggle: booking.toggleServiceFor,\n onContinue: booking.continueFromService,\n total:\n state.items.length === 0\n ? null\n : {\n minutes: kit.wizard.visitMinutes(state.items, state.partyMode),\n priceOre: booking.derived.total,\n },\n canContinue: booking.derived.canAdvance,\n notice:\n state.error === null\n ? null\n : labelText(\n wizardErrorText(\n kit.labels,\n state.error,\n kit.config.party.maxServicesPerPerson\n )\n ),\n labels: kit.labels,\n }\n : undefined\n }\n classNames={resolved.classNames.service}\n components={\n resolved.components.ServiceCard\n ? { ServiceCard: resolved.components.ServiceCard }\n : undefined\n }\n />\n );\n}\n\nfunction WhenStep({ booking, resolved }: StepProps) {\n const { kit, state, catalogue, slots, schedule } = booking;\n const { labels, format, wizard, config } = kit;\n const party = booking.derived.party;\n const items = state.items;\n const phone = config.contact.phone;\n const now = useWallClock(slots.fromTs);\n return (\n <>\n <StylistScreen\n labels={screenLabels(labels)}\n format={format}\n now={now}\n // Not deduplicated: a named stylist has to cover every service of every\n // line — a person's extras included, as their visit is one stylist's.\n serviceIds={items.flatMap((item) => visitServicesOf(item).map((service) => service.id))}\n resources={catalogue.resources}\n loading={!catalogue.resourcesKnown}\n nextAvailableLoading={slots.nextAvailableLoading}\n selectedResourceId={state.resourceId}\n nextAvailableTs={slots.nextAvailableTs}\n pendingName={catalogue.chosenStylistName}\n notice={catalogue.stylistNotice}\n onPick={booking.pickResource}\n firstAvailableFaces={config.screens.firstAvailableFaces}\n edgeFade={config.screens.stylistEdgeFade}\n row={config.screens.wideLayouts}\n party={\n party\n ? {\n mode: state.partyMode,\n size: items.length,\n sizeWord: partySizeWord(labels, items.length),\n minutes: {\n sequential: wizard.visitMinutes(items, 'sequential'),\n parallel: wizard.visitMinutes(items, 'parallel'),\n },\n onMode: (mode) => booking.dispatch({ type: 'setPartyMode', mode }),\n }\n : undefined\n }\n classNames={resolved.classNames.stylist}\n components={\n resolved.components.StylistCard\n ? { StylistCard: resolved.components.StylistCard }\n : undefined\n }\n />\n\n {slots.ready ? (\n <TimeScreen\n labels={screenLabels(labels)}\n format={format}\n dayparts={kit.dayparts}\n monthView\n slots={slots.single}\n days={slots.days}\n openDays={schedule.openDays}\n stylistName={catalogue.chosenStylistName}\n takenSlotTs={slots.takenSlotTs}\n now={now}\n phone={phone}\n weekendNote={(dayTs) => weekendNoteFor(kit, party ? items : items.slice(0, 1), dayTs)}\n onPick={booking.pickSlot}\n soonest={\n config.screens.soonest\n ? {\n resolveStylist: (resourceId) => stylistFace(catalogue.resources, resourceId),\n }\n : undefined\n }\n dayFullness={config.screens.dayFullness}\n layout={config.screens.wideLayouts ? 'adaptive' : 'stack'}\n party={\n party\n ? {\n items,\n mode: state.partyMode,\n slots: slots.party,\n chipEndTs: (slot) => wizard.visitEndTs(items, slot.startTs, 'sequential'),\n // Only a back-to-back family is offered the simultaneous rescue.\n alternativeFor:\n state.partyMode === 'sequential'\n ? (dayTs) =>\n kit.partySlots.partyAlternative({\n sequential: slots.party,\n parallel: slots.alternatives,\n dayTs,\n })\n : undefined,\n resolveStylistName: catalogue.resolveStylistName,\n onPick: booking.pickPartySlot,\n }\n : undefined\n }\n classNames={resolved.classNames.time}\n components={timeComponents(resolved)}\n />\n ) : slots.failed ? (\n <SlotsUnavailable kit={kit} onRetry={booking.refresh} />\n ) : (\n // The step's own shape, so nothing below it moves when it fills in.\n <TimeScreenSkeleton\n labels={screenLabels(labels)}\n days={slots.days.length}\n monthView\n surchargeRow={items.some((item) =>\n visitServicesOf(item).some((service) => service.weekendSurchargePct > 0)\n )}\n />\n )}\n\n {/* Once, outside the skeleton-or-step switch, so a re-read cannot remount it. */}\n <TakenToast labels={screenLabels(labels)} takenSlotTs={slots.takenSlotTs} />\n </>\n );\n}\n\n/**\n * The details step's recap (`config.screens.recap`): the day, hours, who and\n * total of the booking the form below submits, «edit» back to the time step,\n * and a swap to another stylist free at that minute.\n */\nfunction RecapStep({ booking, resolved }: StepProps) {\n const { kit, state, catalogue, slots } = booking;\n const recap = useMemo(\n () => recapProps(kit, state, catalogue.resources, slots.single),\n [kit, state, catalogue.resources, slots.single]\n );\n return (\n <BookingRecap\n lines={recap.lines}\n totalOre={booking.derived.total}\n format={kit.format}\n labels={screenLabels(kit.labels)}\n onEdit={() => booking.dispatch({ type: 'goToStep', step: 'when' })}\n alternatives={recap.alternatives}\n // Alternatives exist only for a timed visit, so there is always a start to keep.\n onSwap={(resourceId) => booking.pickSlot({ startTs: state.startTs as number, resourceId })}\n classNames={resolved.classNames.recap}\n />\n );\n}\n\nfunction timeComponents({ components }: ResolvedBooking) {\n if (!components.DayChip && !components.TimeChip) return undefined;\n return { DayChip: components.DayChip, TimeChip: components.TimeChip };\n}\n\nfunction DetailsStep({ booking, resolved }: StepProps) {\n const { kit, state } = booking;\n const guardian = booking.login.guardian;\n const lines = useMemo(() => detailsLines(kit, state), [kit, state]);\n return (\n <DetailsScreen\n state={forMedaScreen(state)}\n onChange={booking.dispatch}\n onSubmit={booking.submit}\n lines={lines}\n totalOre={booking.derived.total}\n {...detailsScreenBase(kit)}\n submitting={booking.submitting}\n submissionNonce={booking.submissionNonce}\n family={guardian?.family}\n guardianPhone={guardian?.phone ?? null}\n // The address they logged in with: the booking is theirs, by that address.\n emailReadOnly={kit.config.account.required && guardian !== null}\n format={kit.format}\n labels={screenLabels(kit.labels)}\n classNames={resolved.classNames.details}\n components={\n resolved.components.FamilyChip ? { FamilyChip: resolved.components.FamilyChip } : undefined\n }\n />\n );\n}\n\n/**\n * «Bekreft» under `account.required` for a parent not logged in: «Nesten\n * ferdig», a line, and the login — Vipps, or e-mail — in place of the form.\n * The heading carries the details step's id, so the focus a step change moves\n * lands here, and the form's heading takes it over once the parent is in.\n */\nfunction AccountGate({\n booking,\n resolved,\n actions,\n overrides,\n onSignedIn,\n}: StepProps & {\n actions: NonNullable<BookingWizardProps['actions']>;\n overrides: BookingOverrides;\n onSignedIn: (guardian: Parameters<BookingController['login']['signIn']>[0]) => void;\n}) {\n const { labels } = booking.kit;\n const { classNames } = resolved;\n // The details screen's own root and heading slots, after the same defaults.\n const slot = (base: string, extra: string | undefined) => [base, extra].filter(Boolean).join(' ');\n return (\n <section\n aria-labelledby={STEP_HEADINGS.details}\n className={slot('space-y-6', classNames.details?.root)}\n >\n <h2\n id={STEP_HEADINGS.details}\n tabIndex={-1}\n className={slot(\n 'font-sans text-2xl font-bold outline-none md:text-3xl',\n classNames.details?.heading\n )}\n >\n {labelText(labels['wizard.account.heading'])}\n </h2>\n <LiveStatus\n text={booking.login.sessionLost ? labelText(labels['wizard.account.sessionLost']) : null}\n className=\"text-sm font-medium empty:hidden\"\n />\n <p className=\"text-muted-foreground\">{labelText(labels['wizard.account.intro'])}</p>\n {/* What the login is for: the hour held, in the summary bar's own words and slot. */}\n <p\n className={slot(\n 'rounded-lg border border-border bg-card px-4 py-3 text-sm font-medium',\n classNames.summary?.line\n )}\n >\n {booking.derived.summary}\n </p>\n <LoginSheet\n {...overridesOf(overrides)}\n actions={actions}\n presentation=\"gate\"\n resumePath={booking.login.resumePath}\n vippsConfirm={booking.login.vippsConfirm}\n onSignedIn={onSignedIn}\n />\n </section>\n );\n}\n\nfunction ConfirmationStep({ booking, resolved, siteUrl }: StepProps & { siteUrl?: string }) {\n const { kit } = booking;\n // Read once, so «today» cannot become «tomorrow» halfway down the card.\n const [now] = useState(() => Date.now());\n const confirmation = booking.confirmed as BookingConfirmation;\n return (\n <Confirmation\n {...confirmationProps(kit, confirmation, siteUrl)}\n onStartOver={booking.startOver}\n now={now}\n format={kit.format}\n labels={screenLabels(kit.labels)}\n classNames={resolved.classNames.confirmation}\n components={\n resolved.components.PartyLine ? { PartyLine: resolved.components.PartyLine } : undefined\n }\n />\n );\n}\n\n/**\n * «Have an account? Log in» — or, once the parent is known, nothing at all\n * for one who arrived logged in, and one line saying so for one who logged in\n * from the sheet.\n */\nfunction LoginRow({\n booking,\n actions,\n overrides,\n onSignedIn,\n}: {\n booking: BookingController;\n actions: NonNullable<BookingWizardProps['actions']>;\n overrides: BookingOverrides;\n onSignedIn: (guardian: Parameters<BookingController['login']['signIn']>[0]) => void;\n}) {\n const { arrivedAs, signedIn, resumePath } = booking.login;\n const { labels } = booking.kit;\n if (signedIn === null && arrivedAs !== null) return null;\n // Named only by the login itself: an unreadable profile is «Logget inn»,\n // never the name of whoever the page arrived as.\n const who = signedIn?.guardian ?? null;\n const name =\n who === null\n ? null\n : [who.firstName, who.lastName].filter(Boolean).join(' ').trim() || who.email;\n const confirmation =\n signedIn === null\n ? null\n : name === null\n ? labelText(labels['wizard.signedIn'])\n : fill(labels['wizard.signedInAs'], { name });\n return (\n <div>\n {signedIn === null && (\n <LoginSheet\n {...overridesOf(overrides)}\n actions={actions}\n presentation=\"sheet\"\n resumePath={resumePath}\n onSignedIn={onSignedIn}\n />\n )}\n <LiveStatus text={confirmation} className=\"text-sm text-muted-foreground empty:hidden\" />\n </div>\n );\n}\n\n/** The neutral shell the restore gate shows, the header's and a step's shape. */\nfunction RestoreSkeleton({ text }: { text: string }) {\n return (\n <div data-testid=\"restore-skeleton\">\n {/* Outside the busy subtree: assistive tech may hold back what is inside it. */}\n <LiveStatus text={text} />\n <div aria-busy=\"true\" className=\"space-y-8\">\n <div aria-hidden=\"true\" className=\"space-y-3\">\n <div className=\"flex items-center gap-3\">\n <span className=\"size-9 shrink-0 animate-pulse rounded-full bg-muted\" />\n <span className=\"flex flex-col gap-1.5\">\n <span className=\"h-4 w-24 animate-pulse rounded bg-muted\" />\n <span className=\"h-3 w-36 animate-pulse rounded bg-muted\" />\n </span>\n </div>\n <span className=\"block h-1.5 w-full animate-pulse rounded-full bg-muted\" />\n </div>\n <div aria-hidden=\"true\" className=\"space-y-6\">\n <span className=\"block h-8 w-56 animate-pulse rounded bg-muted md:h-9\" />\n <div className=\"space-y-2\">\n {Array.from({ length: 4 }, (_, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: four identical placeholders.\n <span key={index} className=\"block h-24 animate-pulse rounded-lg bg-muted\" />\n ))}\n </div>\n </div>\n </div>\n </div>\n );\n}\n\n/**\n * Where the visitor is, the way back, and how much is left: a back arrow, the\n * title and «step N of 4», and one segment per step that IS the button to it —\n * tappable only where the machine's `canGoToStep` says so, and frozen while a\n * submission is in flight.\n */\nfunction WizardHeader({\n kit,\n state,\n submitting,\n onGo,\n}: {\n kit: BookingKit;\n state: WizardState;\n submitting: boolean;\n onGo: (step: WizardStep) => void;\n}) {\n const { labels, wizard } = kit;\n const stepLabel = (step: WizardStep) => labelText(labels[`wizard.step.${step}`]);\n const index = STEPS.indexOf(state.step);\n const previous = index > 0 ? STEPS[index - 1] : null;\n const canGoBack = previous !== null && !submitting && wizard.canGoToStep(state, previous);\n\n return (\n <div className=\"space-y-3\">\n <div className=\"flex items-center gap-3\">\n {canGoBack ? (\n <button\n type=\"button\"\n aria-label={fill(labels['wizard.back'], { label: stepLabel(previous) })}\n onClick={() => onGo(previous)}\n className=\"flex size-9 shrink-0 items-center justify-center rounded-full border border-border text-foreground transition-colors hover:bg-muted\"\n >\n <ArrowLeft aria-hidden=\"true\" className=\"size-4\" />\n </button>\n ) : (\n // Held rather than dropped, so the title does not shuffle sideways.\n <span aria-hidden=\"true\" className=\"size-9 shrink-0\" />\n )}\n <p className=\"min-w-0\">\n <span className=\"block font-sans text-sm font-bold\">{labels['wizard.title']}</span>\n <span className=\"block truncate text-xs text-muted-foreground\">\n {fillParts(labels['wizard.progress'], {\n step: index + 1,\n total: STEPS.length,\n label: stepLabel(state.step),\n })}\n </span>\n </p>\n </div>\n\n <ol\n aria-label={labelText(labels['wizard.progressLabel'])}\n className=\"flex items-center gap-1.5\"\n >\n {STEPS.map((step, position) => {\n const isCurrent = step === state.step;\n const reachable = !submitting && !isCurrent && wizard.canGoToStep(state, step);\n return (\n <li key={step} className=\"flex-1\">\n <button\n type=\"button\"\n disabled={!reachable}\n aria-current={isCurrent ? 'step' : undefined}\n onClick={() => onGo(step)}\n className={`block h-1.5 w-full rounded-full transition-colors ${\n position <= index ? 'bg-primary' : 'bg-border'\n } ${reachable ? 'cursor-pointer' : 'cursor-default'}`}\n >\n <span className=\"sr-only\">{stepLabel(step)}</span>\n </button>\n </li>\n );\n })}\n </ol>\n </div>\n );\n}\n\n/**\n * The openings could not be read — this site's failure, not a full day —\n * with the telephone as the rescue and a retry as the cheaper first try.\n */\nfunction SlotsUnavailable({ kit, onRetry }: { kit: BookingKit; onRetry: () => void }) {\n const { labels, config, format } = kit;\n const phone = config.contact.phone;\n return (\n <div className=\"space-y-3\">\n <p role=\"alert\" className=\"rounded-lg border border-border bg-card px-5 py-4 text-sm\">\n {labels['wizard.slotsUnavailable.lead']}\n {phone ? (\n <a\n href={format.telHref(phone)}\n aria-label={fill(labels['wizard.slotsUnavailable.callAria'], { phone })}\n className=\"font-semibold text-primary underline underline-offset-4\"\n >\n {fillParts(labels['wizard.slotsUnavailable.call'], { phone })}\n </a>\n ) : (\n labels['wizard.slotsUnavailable.callPlain']\n )}\n {labels['wizard.slotsUnavailable.suffix']}\n </p>\n <BookingButtonOutline onClick={onRetry}>{labels['wizard.retry']}</BookingButtonOutline>\n </div>\n );\n}\n\nfunction BookingButtonOutline({ onClick, children }: { onClick: () => void; children: ReactNode }) {\n return (\n <BookingButton variant=\"outline\" onClick={onClick}>\n {children}\n </BookingButton>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"BookingWizard.mjs","names":["LoginSheet"],"sources":["../../src/react/BookingWizard.tsx"],"sourcesContent":["/**\n * `<BookingWizard>` — the booking flow, from «who is it for?» to the\n * confirmation, drawn with the `@medalsocial/meda/booking` screens over\n * `useBooking()`.\n *\n * The hook owns the machine, the fetches and the stores; this file owns the\n * page: which step is on screen (ONE at a time — the details step's nonce and\n * the submit flag depend on it), focus and scroll on every move, the restore\n * gate's pre-hydration script and skeleton, the header, and what each screen\n * is handed (prices, the weekend note, ages, the possessive, the calendar\n * file) out of the site's config.\n */\n\nimport {\n BookingButton,\n BookingRecap,\n Confirmation,\n DetailsScreen,\n LiveStatus,\n ServiceScreen,\n StylistScreen,\n SummaryBar,\n TakenToast,\n TimeScreen,\n TimeScreenSkeleton,\n WhoScreen,\n} from '@medalsocial/meda/booking';\nimport { ArrowLeft } from 'lucide-react';\nimport {\n type ReactNode,\n Suspense,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n} from 'react';\nimport { fill, fillParts, labelText } from '../core/labels';\nimport { SELF_KEY, type WizardPerson, type WizardState, type WizardStep } from '../core/machine';\nimport type { BookingServiceDto } from '../core/types';\nimport { visitServicesOf } from '../core/visit';\nimport type { PortalActions } from './actions';\nimport type { BookingKit } from './kit';\nimport { LoginSheet } from './LoginSheet';\nimport type { BookingOverrides, ResolvedBooking } from './Provider';\nimport { useBookingKit } from './Provider';\nimport { screenLabels } from './screen-labels';\nimport {\n type BookingConfirmation,\n type BookingController,\n type BookingReadEndpoint,\n type BookingSeed,\n useBooking,\n} from './useBooking';\nimport {\n addedChildEntries,\n confirmationProps,\n detailsLines,\n detailsScreenBase,\n familyEntries,\n GUEST_ADULT_SEAT,\n guestChoices,\n partyPeople,\n partySizeWord,\n recapProps,\n serviceFitsFor,\n serviceScreenBase,\n stylistFace,\n weekendNoteFor,\n} from './wizard/adapters';\nimport { useWallClock } from './wizard/wall-clock';\nimport { forMedaScreen, wizardErrorText } from './wizard-error';\n\nexport interface BookingWizardProps extends BookingOverrides {\n /** What the page prefetched (the `/next` loader's seed). */\n seed: BookingSeed;\n /** This request's phone and address, over `config.contact` (the loader's `contact`). */\n contact?: { phone?: string | null; address?: string | null };\n /** The parent the page found a portal session for; `null` for a guest. */\n guardian?: BookingGuardianProp;\n /**\n * The app's server actions for the login sheet (Decision 8). Without them —\n * or with `config.portal.enabled` off — no login is offered.\n */\n actions?: Pick<PortalActions, 'startLogin' | 'startVipps'>;\n /** How many days the window covers. Default `config.window.rangeDays`. */\n rangeDays?: number;\n /**\n * The site's origin (`https://…`), so the manage links inside the calendar\n * file are absolute. Absent, they stay root-relative.\n */\n siteUrl?: string;\n /** A monitoring tap: the step the visitor is on, and a submission's outcome. */\n onEvent?: (event: BookingWizardEvent) => void;\n}\n\ntype BookingGuardianProp = Parameters<typeof useBooking>[0]['guardian'];\n\nexport type BookingWizardEvent =\n | { type: 'step'; step: WizardStep | 'confirmed' }\n | { type: 'submit_ok'; bookings: number }\n | { type: 'submit_error'; code: NonNullable<WizardState['error']> }\n /** A read the time step waits on failed: `status` is `null` when nothing answered. */\n | { type: 'read_error'; endpoint: BookingReadEndpoint; status: number | null };\n\n/** The bundler's build mode, which it inlines; absent outside a bundler. */\ndeclare const process: { env: { NODE_ENV?: string } } | undefined;\n\n/** The wizard's root, for the inline restore gate to find before hydration. */\nconst WIZARD_ROOT_ID = 'booking-wizard';\n\n/** The time step's heading, which a lost slot sends the visitor back to. */\nconst TIME_HEADING_ID = 'booking-time-heading';\n\n/** The heading each screen focuses when the visitor arrives on it. */\nconst STEP_HEADINGS: Record<WizardStep | 'confirmed', string> = {\n who: 'booking-who-heading',\n service: 'booking-service-heading',\n when: 'booking-stylist-heading',\n details: 'booking-details-heading',\n confirmed: 'booking-confirmed-heading',\n};\n\nconst STEPS: readonly WizardStep[] = ['who', 'service', 'when', 'details'];\n\n/** Smooth, or instant for a visitor who asked for reduced motion. */\nfunction scrollToTop(target: HTMLElement | null): void {\n if (!target || typeof target.scrollIntoView !== 'function') return;\n const reduced =\n typeof window.matchMedia === 'function' &&\n window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n target.scrollIntoView({ behavior: reduced ? 'auto' : 'smooth', block: 'start' });\n}\n\n/**\n * The wizard, behind its own Suspense boundary: `useSearchParams` throws to\n * the nearest one during a static prerender, and without ours that is the\n * root layout's — the whole page would ship empty until hydration.\n */\nexport function BookingWizard(props: BookingWizardProps) {\n return (\n <Suspense fallback={null}>\n <BookingWizardShell {...props} />\n </Suspense>\n );\n}\n\nfunction BookingWizardShell(props: BookingWizardProps) {\n const resolved = useBookingKit(props, props.contact);\n const loginOffered = props.actions !== undefined && resolved.kit.config.portal.enabled;\n const booking = useBooking({ ...props, loginOffered });\n const { kit } = booking;\n const { labels } = kit;\n\n // `account.required` with no way to log in: every «Bekreft» would be refused.\n const unreachableLogin = kit.config.account.required && !loginOffered;\n useEffect(() => {\n if (\n unreachableLogin &&\n typeof process !== 'undefined' &&\n process.env.NODE_ENV !== 'production'\n ) {\n console.warn('[booking] account.required without login actions: nobody can log in to book.');\n }\n }, [unreachableLogin]);\n const { state, restore, confirmed, onEvent } = { ...booking, onEvent: props.onEvent };\n\n /**\n * On every change of screen: the wizard's top into view and focus on the\n * new step's heading. Never on the first screen, and never for what the\n * restore gate reveals — that is the page arriving, not the visitor moving.\n */\n const rootRef = useRef<HTMLDivElement>(null);\n const screenKey: WizardStep | 'confirmed' = confirmed !== null ? 'confirmed' : state.step;\n const shownScreen = useRef<WizardStep | 'confirmed' | null>(null);\n const takenSlotTs = booking.slots.takenSlotTs;\n const timeStepReady = booking.slots.ready;\n const focusTimeWhenReady = useRef(false);\n useEffect(() => {\n if (restore.restoring) return;\n const previous = shownScreen.current;\n shownScreen.current = screenKey;\n if (previous === screenKey) return;\n onEvent?.({ type: 'step', step: screenKey });\n if (previous === null) return;\n // A lost slot sends the visitor back for the ALTERNATIVES, on the time half.\n const toTimes = screenKey === 'when' && takenSlotTs !== null;\n const headingId = toTimes ? TIME_HEADING_ID : STEP_HEADINGS[screenKey];\n const heading = document.getElementById(headingId);\n scrollToTop(toTimes ? heading : rootRef.current);\n heading?.focus({ preventScroll: true });\n // The heading focused now may be the skeleton's; focus follows the real one.\n focusTimeWhenReady.current = toTimes && !timeStepReady;\n }, [screenKey, restore.restoring, takenSlotTs, timeStepReady, onEvent]);\n\n /** After a login from the sheet the row that opened it is gone: focus the step's heading. */\n const focusAfterSignIn = useRef(false);\n const signedIn = booking.login.signedIn;\n useEffect(() => {\n if (signedIn === null || !focusAfterSignIn.current) return;\n focusAfterSignIn.current = false;\n document.getElementById(STEP_HEADINGS[state.step])?.focus({ preventScroll: true });\n }, [signedIn, state.step]);\n /**\n * A 401 put the gate back over the form: focus its heading (the details\n * step's id), where the notice beside it says why. Once per loss, and only\n * once the gate is actually on screen — a replay answers behind the skeleton.\n */\n const sessionLost = booking.login.sessionLost;\n const sessionLossFocused = useRef(false);\n useEffect(() => {\n if (!sessionLost) {\n sessionLossFocused.current = false;\n return;\n }\n if (sessionLossFocused.current || restore.restoring || state.step !== 'details') return;\n sessionLossFocused.current = true;\n const heading = document.getElementById(STEP_HEADINGS.details);\n scrollToTop(rootRef.current);\n heading?.focus({ preventScroll: true });\n }, [sessionLost, restore.restoring, state.step]);\n useEffect(() => {\n if (!timeStepReady || !focusTimeWhenReady.current) return;\n focusTimeWhenReady.current = false;\n const active = document.activeElement;\n if (active === null || active === document.body) {\n document.getElementById(TIME_HEADING_ID)?.focus({ preventScroll: true });\n }\n }, [timeStepReady]);\n\n // The monitoring tap for a submission's outcome.\n const lastError = useRef(state.error);\n useEffect(() => {\n if (state.error !== null && state.error !== lastError.current) {\n onEvent?.({ type: 'submit_error', code: state.error });\n }\n lastError.current = state.error;\n }, [state.error, onEvent]);\n // The monitoring tap for a failed read — what the visitor saw as «Vi får ikke\n // hentet ledige tider». Once per failure (`seq`), not per render.\n // Keyed on `seq` in a ref, like `lastError` above: the effect also re-runs when\n // an inline `onEvent` changes identity, which must not report the failure again.\n const { readErrors } = booking;\n const lastReadSeq = useRef(0);\n useEffect(() => {\n for (const failure of readErrors) {\n if (failure.seq <= lastReadSeq.current) continue;\n lastReadSeq.current = failure.seq;\n onEvent?.({ type: 'read_error', endpoint: failure.endpoint, status: failure.status });\n }\n }, [readErrors, onEvent]);\n const confirmedCount = confirmed?.bookings.length ?? 0;\n useEffect(() => {\n if (confirmedCount > 0) onEvent?.({ type: 'submit_ok', bookings: confirmedCount });\n }, [confirmedCount, onEvent]);\n\n // React owns the root from here on: drop the pre-hydration mark the inline\n // script may have set, and say so, so the script's fallback timer stands down.\n useLayoutEffect(() => {\n if (!restore.hydrated) return;\n rootRef.current?.removeAttribute('data-restoring');\n rootRef.current?.setAttribute('data-hydrated', '');\n }, [restore.hydrated]);\n\n // Steps animate in only once the visitor has moved between them.\n const [arrivalStep, setArrivalStep] = useState<WizardStep | null>(\n restore.restoring ? null : state.step\n );\n if (!restore.restoring && arrivalStep === null) setArrivalStep(state.step);\n const [stepsMovedOnce, setStepsMovedOnce] = useState(false);\n const movedNow = arrivalStep !== null && !restore.restoring && state.step !== arrivalStep;\n if (movedNow && !stepsMovedOnce) setStepsMovedOnce(true);\n const stepsMoved = stepsMovedOnce || movedNow;\n\n const restoreSkeleton = <RestoreSkeleton text={labelText(labels['wizard.restoring'])} />;\n /**\n * The root both screens share: the scroll target, and the element the\n * inline restore gate marks. The script and the hidden skeleton exist only\n * in the server's HTML and the hydration pass that adopts it.\n */\n const shell = (content: ReactNode) => (\n <div\n id={WIZARD_ROOT_ID}\n ref={rootRef}\n className={resolved.classNames.wizard?.root ?? 'group scroll-mt-28'}\n suppressHydrationWarning\n >\n {!restore.hydrated && (\n <>\n <script\n // A fixed script built from constants, run before hydration.\n // biome-ignore lint/security/noDangerouslySetInnerHtml: the restore gate must run before React does.\n dangerouslySetInnerHTML={{\n __html: kit.restoreGate.restoreGateScript(\n WIZARD_ROOT_ID,\n restore.linked,\n restore.resuming\n ),\n }}\n />\n <div className=\"hidden group-data-[restoring]:block\">{restoreSkeleton}</div>\n </>\n )}\n {restore.restoring && restore.hydrated ? (\n restoreSkeleton\n ) : (\n <div className=\"group-data-[restoring]:hidden\">{content}</div>\n )}\n </div>\n );\n\n if (confirmed !== null) {\n return shell(\n <ConfirmationStep booking={booking} resolved={resolved} siteUrl={props.siteUrl} />\n );\n }\n\n const onSignedIn = (who: Parameters<BookingController['login']['signIn']>[0]) => {\n focusAfterSignIn.current = true;\n booking.login.signIn(who);\n };\n // The multi-select service step carries its own total bar and refusal notice.\n const multiSelect = state.step === 'service' && kit.config.party.maxServicesPerPerson > 1;\n /**\n * `account.required`: every booking is a logged-in parent's. The login is\n * not offered along the way — it IS «Bekreft», for a parent not logged in.\n */\n const accountRequired = kit.config.account.required && loginOffered;\n const gated = accountRequired && state.step === 'details' && !booking.login.loggedIn;\n const loginRow =\n loginOffered && !accountRequired ? (\n <LoginRow\n booking={booking}\n actions={props.actions as NonNullable<BookingWizardProps['actions']>}\n overrides={props}\n onSignedIn={onSignedIn}\n />\n ) : null;\n\n return shell(\n <div className=\"space-y-8\">\n <WizardHeader\n kit={kit}\n state={state}\n submitting={booking.submitting}\n onGo={(step) => booking.dispatch({ type: 'goToStep', step })}\n />\n\n {/* The machine's error, on the steps that have nowhere else to put it. */}\n {state.error !== null &&\n state.step !== 'details' &&\n state.step !== 'when' &&\n !multiSelect && (\n <p\n role=\"alert\"\n className={\n resolved.classNames.wizard?.alert ??\n 'rounded-lg border border-destructive/40 bg-destructive/10 px-5 py-3 text-sm'\n }\n >\n {wizardErrorText(labels, state.error, kit.config.party.maxServicesPerPerson)}\n </p>\n )}\n\n {/* What is being booked, first: above the login offer and the form. */}\n {state.step === 'details' && !gated && kit.config.screens.recap && (\n <RecapStep booking={booking} resolved={resolved} />\n )}\n\n {state.step === 'details' && loginRow}\n\n {/* The gate takes a Vipps confirm code itself, in place of the form. */}\n {loginOffered && booking.login.vippsConfirm !== null && signedIn === null && !gated && (\n <LoginSheet\n {...overridesOf(props)}\n actions={props.actions as NonNullable<BookingWizardProps['actions']>}\n presentation=\"sheet\"\n trigger={false}\n vippsConfirm={booking.login.vippsConfirm}\n resumePath={booking.login.resumePath}\n onSignedIn={onSignedIn}\n />\n )}\n\n {/* One box per step, keyed by it, so a step arriving is a mount. */}\n <div\n key={state.step}\n data-booking-step={state.step}\n data-animate={stepsMoved ? '' : undefined}\n className=\"space-y-8\"\n >\n {state.step === 'who' && (\n <WhoStep booking={booking} resolved={resolved} loginRow={loginRow} />\n )}\n {state.step === 'service' && (\n <ServiceStep booking={booking} resolved={resolved} multiSelect={multiSelect} />\n )}\n {state.step === 'when' && <WhenStep booking={booking} resolved={resolved} />}\n {state.step === 'details' &&\n (gated ? (\n <AccountGate\n booking={booking}\n resolved={resolved}\n actions={props.actions as NonNullable<BookingWizardProps['actions']>}\n overrides={props}\n onSignedIn={onSignedIn}\n />\n ) : (\n <DetailsStep booking={booking} resolved={resolved} />\n ))}\n </div>\n\n {!multiSelect && (\n <SummaryBar\n {...(kit.config.screens.summaryDetail\n ? booking.derived.summaryParts\n : { line: booking.derived.summary })}\n canAdvance={booking.derived.canAdvance}\n step={state.step}\n onNext={booking.next}\n hideNextWhenDisabled={kit.config.screens.hideDisabledNext}\n labels={screenLabels(labels)}\n classNames={resolved.classNames.summary}\n />\n )}\n </div>\n );\n}\n\n/** The overrides a nested booking component inherits from the wizard's props. */\nfunction overridesOf(props: BookingOverrides): BookingOverrides {\n return {\n config: props.config,\n labels: props.labels,\n classNames: props.classNames,\n components: props.components,\n };\n}\n\ninterface StepProps {\n booking: BookingController;\n resolved: ResolvedBooking;\n}\n\nfunction WhoStep({ booking, resolved, loginRow }: StepProps & { loginRow: ReactNode }) {\n const { kit, state, people } = booking;\n const guests = useMemo(() => guestChoices(kit), [kit]);\n return (\n <WhoScreen\n labels={screenLabels(kit.labels)}\n format={kit.format}\n people={state.people}\n family={people.family === null ? null : familyEntries(kit, people.family, people.ageDayKey)}\n guestChoices={guests}\n addedChildren={addedChildEntries(kit, state.people, people.ageDayKey)}\n maxPeople={kit.wizard.maxPeople}\n selfKey={SELF_KEY}\n isGuestSeat={kit.wizard.isGuestSeat}\n onChoose={people.choosePeople}\n onAddChild={people.addChild}\n guestParty={\n kit.config.screens.guestParty\n ? { child: kit.wizard.guestChild, adult: GUEST_ADULT_SEAT }\n : undefined\n }\n loginRow={loginRow}\n currentYear={Number(kit.clock.dayKey(booking.slots.fromTs).slice(0, 4))}\n classNames={resolved.classNames.who}\n components={\n resolved.components.PersonCard ? { PersonCard: resolved.components.PersonCard } : undefined\n }\n />\n );\n}\n\nfunction ServiceStep({ booking, resolved, multiSelect }: StepProps & { multiSelect: boolean }) {\n const { kit, state, people, catalogue } = booking;\n const ageOf = (person: WizardPerson) => people.personAge(person, people.ageDayKey);\n const single = state.people.length === 1 ? state.people[0] : null;\n // «Samme som sist» stays a one-tap shortcut while the person's visit is\n // nothing, or just that service (a returning child is seated with it already\n // ticked). Once they have ticked something else, a one-tap pick would REPLACE\n // their visit — and, alone, jump to the time step — so the suggestion joins\n // the visit instead, and never unticks a service already in it.\n const pickFor = (index: number, service: BookingServiceDto, oneTap: () => void) => {\n const visit = state.choices[index] ? [state.choices[index], ...state.extras[index]] : [];\n if (!multiSelect || visit.length === 0 || (visit.length === 1 && visit[0].id === service.id)) {\n oneTap();\n } else if (!visit.some((ticked) => ticked.id === service.id)) {\n booking.toggleServiceFor(index, service);\n }\n };\n return (\n <ServiceScreen\n labels={screenLabels(kit.labels)}\n format={kit.format}\n services={catalogue.services}\n {...serviceScreenBase(kit)}\n serviceFits={serviceFitsFor(kit, state.people.map(ageOf), state.people)}\n initialCategory={catalogue.deepLinkCategory}\n onPick={(service) => pickFor(0, service, () => booking.pickService(service))}\n suggestion={single ? people.suggestionFor(single) : null}\n chosenId={state.choices[0]?.id ?? null}\n childName={single ? (single.name ?? null) : null}\n party={\n state.people.length > 1\n ? {\n people: partyPeople(kit, state.people, ageOf, people.suggestionFor),\n choices: state.choices,\n onPickFor: (index, service) =>\n pickFor(index, service, () => booking.pickServiceFor(index, service)),\n onRemove: (index) =>\n booking.dispatch({\n type: 'choosePeople',\n people: state.people.filter((_, position) => position !== index),\n }),\n }\n : undefined\n }\n selection={\n multiSelect\n ? {\n // Index = seated person; meda reads a missing list (nobody seated) as empty.\n lists: state.choices.map((first, index) =>\n first ? [first, ...state.extras[index]] : []\n ),\n onToggle: booking.toggleServiceFor,\n onContinue: booking.continueFromService,\n total:\n state.items.length === 0\n ? null\n : {\n minutes: kit.wizard.visitMinutes(state.items, state.partyMode),\n priceOre: booking.derived.total,\n },\n canContinue: booking.derived.canAdvance,\n notice:\n state.error === null\n ? null\n : labelText(\n wizardErrorText(\n kit.labels,\n state.error,\n kit.config.party.maxServicesPerPerson\n )\n ),\n labels: kit.labels,\n }\n : undefined\n }\n classNames={resolved.classNames.service}\n components={\n resolved.components.ServiceCard\n ? { ServiceCard: resolved.components.ServiceCard }\n : undefined\n }\n />\n );\n}\n\nfunction WhenStep({ booking, resolved }: StepProps) {\n const { kit, state, catalogue, slots, schedule } = booking;\n const { labels, format, wizard, config } = kit;\n const party = booking.derived.party;\n const items = state.items;\n const phone = config.contact.phone;\n const now = useWallClock(slots.fromTs);\n return (\n <>\n <StylistScreen\n labels={screenLabels(labels)}\n format={format}\n now={now}\n // Not deduplicated: a named stylist has to cover every service of every\n // line — a person's extras included, as their visit is one stylist's.\n serviceIds={items.flatMap((item) => visitServicesOf(item).map((service) => service.id))}\n resources={catalogue.resources}\n loading={!catalogue.resourcesKnown}\n nextAvailableLoading={slots.nextAvailableLoading}\n selectedResourceId={state.resourceId}\n nextAvailableTs={slots.nextAvailableTs}\n pendingName={catalogue.chosenStylistName}\n notice={catalogue.stylistNotice}\n onPick={booking.pickResource}\n firstAvailableFaces={config.screens.firstAvailableFaces}\n edgeFade={config.screens.stylistEdgeFade}\n row={config.screens.wideLayouts}\n party={\n party\n ? {\n mode: state.partyMode,\n size: items.length,\n sizeWord: partySizeWord(labels, items.length),\n minutes: {\n sequential: wizard.visitMinutes(items, 'sequential'),\n parallel: wizard.visitMinutes(items, 'parallel'),\n },\n onMode: (mode) => booking.dispatch({ type: 'setPartyMode', mode }),\n }\n : undefined\n }\n classNames={resolved.classNames.stylist}\n components={\n resolved.components.StylistCard\n ? { StylistCard: resolved.components.StylistCard }\n : undefined\n }\n />\n\n {slots.ready ? (\n <TimeScreen\n labels={screenLabels(labels)}\n format={format}\n dayparts={kit.dayparts}\n monthView\n slots={slots.single}\n days={slots.days}\n openDays={schedule.openDays}\n stylistName={catalogue.chosenStylistName}\n takenSlotTs={slots.takenSlotTs}\n now={now}\n phone={phone}\n weekendNote={(dayTs) => weekendNoteFor(kit, party ? items : items.slice(0, 1), dayTs)}\n onPick={booking.pickSlot}\n soonest={\n config.screens.soonest\n ? {\n resolveStylist: (resourceId) => stylistFace(catalogue.resources, resourceId),\n }\n : undefined\n }\n dayFullness={config.screens.dayFullness}\n layout={config.screens.wideLayouts ? 'adaptive' : 'stack'}\n party={\n party\n ? {\n items,\n mode: state.partyMode,\n slots: slots.party,\n chipEndTs: (slot) => wizard.visitEndTs(items, slot.startTs, 'sequential'),\n // Only a back-to-back family is offered the simultaneous rescue.\n alternativeFor:\n state.partyMode === 'sequential'\n ? (dayTs) =>\n kit.partySlots.partyAlternative({\n sequential: slots.party,\n parallel: slots.alternatives,\n dayTs,\n })\n : undefined,\n resolveStylistName: catalogue.resolveStylistName,\n onPick: booking.pickPartySlot,\n }\n : undefined\n }\n classNames={resolved.classNames.time}\n components={timeComponents(resolved)}\n />\n ) : slots.failed ? (\n <SlotsUnavailable kit={kit} onRetry={booking.refresh} />\n ) : (\n // The step's own shape, so nothing below it moves when it fills in.\n <TimeScreenSkeleton\n labels={screenLabels(labels)}\n days={slots.days.length}\n monthView\n surchargeRow={items.some((item) =>\n visitServicesOf(item).some((service) => service.weekendSurchargePct > 0)\n )}\n />\n )}\n\n {/* Once, outside the skeleton-or-step switch, so a re-read cannot remount it. */}\n <TakenToast labels={screenLabels(labels)} takenSlotTs={slots.takenSlotTs} />\n </>\n );\n}\n\n/**\n * The details step's recap (`config.screens.recap`): the day, hours, who and\n * total of the booking the form below submits, «edit» back to the time step,\n * and a swap to another stylist free at that minute.\n */\nfunction RecapStep({ booking, resolved }: StepProps) {\n const { kit, state, catalogue, slots } = booking;\n const recap = useMemo(\n () => recapProps(kit, state, catalogue.resources, slots.single),\n [kit, state, catalogue.resources, slots.single]\n );\n return (\n <BookingRecap\n lines={recap.lines}\n totalOre={booking.derived.total}\n format={kit.format}\n labels={screenLabels(kit.labels)}\n onEdit={() => booking.dispatch({ type: 'goToStep', step: 'when' })}\n alternatives={recap.alternatives}\n // Alternatives exist only for a timed visit, so there is always a start to keep.\n onSwap={(resourceId) => booking.pickSlot({ startTs: state.startTs as number, resourceId })}\n classNames={resolved.classNames.recap}\n />\n );\n}\n\nfunction timeComponents({ components }: ResolvedBooking) {\n if (!components.DayChip && !components.TimeChip) return undefined;\n return { DayChip: components.DayChip, TimeChip: components.TimeChip };\n}\n\nfunction DetailsStep({ booking, resolved }: StepProps) {\n const { kit, state } = booking;\n const guardian = booking.login.guardian;\n const lines = useMemo(() => detailsLines(kit, state), [kit, state]);\n return (\n <DetailsScreen\n state={forMedaScreen(state)}\n onChange={booking.dispatch}\n onSubmit={booking.submit}\n lines={lines}\n totalOre={booking.derived.total}\n {...detailsScreenBase(kit)}\n submitting={booking.submitting}\n submissionNonce={booking.submissionNonce}\n family={guardian?.family}\n guardianPhone={guardian?.phone ?? null}\n // The address they logged in with: the booking is theirs, by that address.\n emailReadOnly={kit.config.account.required && guardian !== null}\n format={kit.format}\n labels={screenLabels(kit.labels)}\n classNames={resolved.classNames.details}\n components={\n resolved.components.FamilyChip ? { FamilyChip: resolved.components.FamilyChip } : undefined\n }\n />\n );\n}\n\n/**\n * «Bekreft» under `account.required` for a parent not logged in: «Nesten\n * ferdig», a line, and the login — Vipps, or e-mail — in place of the form.\n * The heading carries the details step's id, so the focus a step change moves\n * lands here, and the form's heading takes it over once the parent is in.\n */\nfunction AccountGate({\n booking,\n resolved,\n actions,\n overrides,\n onSignedIn,\n}: StepProps & {\n actions: NonNullable<BookingWizardProps['actions']>;\n overrides: BookingOverrides;\n onSignedIn: (guardian: Parameters<BookingController['login']['signIn']>[0]) => void;\n}) {\n const { labels } = booking.kit;\n const { classNames } = resolved;\n // The details screen's own root and heading slots, after the same defaults.\n const slot = (base: string, extra: string | undefined) => [base, extra].filter(Boolean).join(' ');\n return (\n <section\n aria-labelledby={STEP_HEADINGS.details}\n className={slot('space-y-6', classNames.details?.root)}\n >\n <h2\n id={STEP_HEADINGS.details}\n tabIndex={-1}\n className={slot(\n 'font-sans text-2xl font-bold outline-none md:text-3xl',\n classNames.details?.heading\n )}\n >\n {labelText(labels['wizard.account.heading'])}\n </h2>\n <LiveStatus\n text={booking.login.sessionLost ? labelText(labels['wizard.account.sessionLost']) : null}\n className=\"text-sm font-medium empty:hidden\"\n />\n <p className=\"text-muted-foreground\">{labelText(labels['wizard.account.intro'])}</p>\n {/* What the login is for: the hour held, in the summary bar's own words and slot. */}\n <p\n className={slot(\n 'rounded-lg border border-border bg-card px-4 py-3 text-sm font-medium',\n classNames.summary?.line\n )}\n >\n {booking.derived.summary}\n </p>\n <LoginSheet\n {...overridesOf(overrides)}\n actions={actions}\n presentation=\"gate\"\n resumePath={booking.login.resumePath}\n vippsConfirm={booking.login.vippsConfirm}\n onSignedIn={onSignedIn}\n />\n </section>\n );\n}\n\nfunction ConfirmationStep({ booking, resolved, siteUrl }: StepProps & { siteUrl?: string }) {\n const { kit } = booking;\n // Read once, so «today» cannot become «tomorrow» halfway down the card.\n const [now] = useState(() => Date.now());\n const confirmation = booking.confirmed as BookingConfirmation;\n return (\n <Confirmation\n {...confirmationProps(kit, confirmation, siteUrl)}\n onStartOver={booking.startOver}\n now={now}\n format={kit.format}\n labels={screenLabels(kit.labels)}\n classNames={resolved.classNames.confirmation}\n components={\n resolved.components.PartyLine ? { PartyLine: resolved.components.PartyLine } : undefined\n }\n />\n );\n}\n\n/**\n * «Have an account? Log in» — or, once the parent is known, nothing at all\n * for one who arrived logged in, and one line saying so for one who logged in\n * from the sheet.\n */\nfunction LoginRow({\n booking,\n actions,\n overrides,\n onSignedIn,\n}: {\n booking: BookingController;\n actions: NonNullable<BookingWizardProps['actions']>;\n overrides: BookingOverrides;\n onSignedIn: (guardian: Parameters<BookingController['login']['signIn']>[0]) => void;\n}) {\n const { arrivedAs, signedIn, resumePath } = booking.login;\n const { labels } = booking.kit;\n if (signedIn === null && arrivedAs !== null) return null;\n // Named only by the login itself: an unreadable profile is «Logget inn»,\n // never the name of whoever the page arrived as.\n const who = signedIn?.guardian ?? null;\n const name =\n who === null\n ? null\n : [who.firstName, who.lastName].filter(Boolean).join(' ').trim() || who.email;\n const confirmation =\n signedIn === null\n ? null\n : name === null\n ? labelText(labels['wizard.signedIn'])\n : fill(labels['wizard.signedInAs'], { name });\n return (\n <div>\n {signedIn === null && (\n <LoginSheet\n {...overridesOf(overrides)}\n actions={actions}\n presentation=\"sheet\"\n resumePath={resumePath}\n onSignedIn={onSignedIn}\n />\n )}\n <LiveStatus text={confirmation} className=\"text-sm text-muted-foreground empty:hidden\" />\n </div>\n );\n}\n\n/** The neutral shell the restore gate shows, the header's and a step's shape. */\nfunction RestoreSkeleton({ text }: { text: string }) {\n return (\n <div data-testid=\"restore-skeleton\">\n {/* Outside the busy subtree: assistive tech may hold back what is inside it. */}\n <LiveStatus text={text} />\n <div aria-busy=\"true\" className=\"space-y-8\">\n <div aria-hidden=\"true\" className=\"space-y-3\">\n <div className=\"flex items-center gap-3\">\n <span className=\"size-9 shrink-0 animate-pulse rounded-full bg-muted\" />\n <span className=\"flex flex-col gap-1.5\">\n <span className=\"h-4 w-24 animate-pulse rounded bg-muted\" />\n <span className=\"h-3 w-36 animate-pulse rounded bg-muted\" />\n </span>\n </div>\n <span className=\"block h-1.5 w-full animate-pulse rounded-full bg-muted\" />\n </div>\n <div aria-hidden=\"true\" className=\"space-y-6\">\n <span className=\"block h-8 w-56 animate-pulse rounded bg-muted md:h-9\" />\n <div className=\"space-y-2\">\n {Array.from({ length: 4 }, (_, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: four identical placeholders.\n <span key={index} className=\"block h-24 animate-pulse rounded-lg bg-muted\" />\n ))}\n </div>\n </div>\n </div>\n </div>\n );\n}\n\n/**\n * Where the visitor is, the way back, and how much is left: a back arrow, the\n * title and «step N of 4», and one segment per step that IS the button to it —\n * tappable only where the machine's `canGoToStep` says so, and frozen while a\n * submission is in flight.\n */\nfunction WizardHeader({\n kit,\n state,\n submitting,\n onGo,\n}: {\n kit: BookingKit;\n state: WizardState;\n submitting: boolean;\n onGo: (step: WizardStep) => void;\n}) {\n const { labels, wizard } = kit;\n const stepLabel = (step: WizardStep) => labelText(labels[`wizard.step.${step}`]);\n const index = STEPS.indexOf(state.step);\n const previous = index > 0 ? STEPS[index - 1] : null;\n const canGoBack = previous !== null && !submitting && wizard.canGoToStep(state, previous);\n\n return (\n <div className=\"space-y-3\">\n <div className=\"flex items-center gap-3\">\n {canGoBack ? (\n <button\n type=\"button\"\n aria-label={fill(labels['wizard.back'], { label: stepLabel(previous) })}\n onClick={() => onGo(previous)}\n className=\"flex size-9 shrink-0 items-center justify-center rounded-full border border-border text-foreground transition-colors hover:bg-muted\"\n >\n <ArrowLeft aria-hidden=\"true\" className=\"size-4\" />\n </button>\n ) : (\n // Held rather than dropped, so the title does not shuffle sideways.\n <span aria-hidden=\"true\" className=\"size-9 shrink-0\" />\n )}\n <p className=\"min-w-0\">\n <span className=\"block font-sans text-sm font-bold\">{labels['wizard.title']}</span>\n <span className=\"block truncate text-xs text-muted-foreground\">\n {fillParts(labels['wizard.progress'], {\n step: index + 1,\n total: STEPS.length,\n label: stepLabel(state.step),\n })}\n </span>\n </p>\n </div>\n\n <ol\n aria-label={labelText(labels['wizard.progressLabel'])}\n className=\"flex items-center gap-1.5\"\n >\n {STEPS.map((step, position) => {\n const isCurrent = step === state.step;\n const reachable = !submitting && !isCurrent && wizard.canGoToStep(state, step);\n return (\n <li key={step} className=\"flex-1\">\n <button\n type=\"button\"\n disabled={!reachable}\n aria-current={isCurrent ? 'step' : undefined}\n onClick={() => onGo(step)}\n className={`block h-1.5 w-full rounded-full transition-colors ${\n position <= index ? 'bg-primary' : 'bg-border'\n } ${reachable ? 'cursor-pointer' : 'cursor-default'}`}\n >\n <span className=\"sr-only\">{stepLabel(step)}</span>\n </button>\n </li>\n );\n })}\n </ol>\n </div>\n );\n}\n\n/**\n * The openings could not be read — this site's failure, not a full day —\n * with the telephone as the rescue and a retry as the cheaper first try.\n */\nfunction SlotsUnavailable({ kit, onRetry }: { kit: BookingKit; onRetry: () => void }) {\n const { labels, config, format } = kit;\n const phone = config.contact.phone;\n return (\n <div className=\"space-y-3\">\n <p role=\"alert\" className=\"rounded-lg border border-border bg-card px-5 py-4 text-sm\">\n {labels['wizard.slotsUnavailable.lead']}\n {phone ? (\n <a\n href={format.telHref(phone)}\n aria-label={fill(labels['wizard.slotsUnavailable.callAria'], { phone })}\n className=\"font-semibold text-primary underline underline-offset-4\"\n >\n {fillParts(labels['wizard.slotsUnavailable.call'], { phone })}\n </a>\n ) : (\n labels['wizard.slotsUnavailable.callPlain']\n )}\n {labels['wizard.slotsUnavailable.suffix']}\n </p>\n <BookingButtonOutline onClick={onRetry}>{labels['wizard.retry']}</BookingButtonOutline>\n </div>\n );\n}\n\nfunction BookingButtonOutline({ onClick, children }: { onClick: () => void; children: ReactNode }) {\n return (\n <BookingButton variant=\"outline\" onClick={onClick}>\n {children}\n </BookingButton>\n );\n}\n"],"mappings":"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"}
@@ -9,7 +9,7 @@ import { BookingLabels, BookingLabelsInput } from "./labels/index.mjs";
9
9
  import { BookingKit, createBookingKit } from "./kit.mjs";
10
10
  import { BookingClassNames, BookingComponents, BookingOverrides, BookingProvider, BookingProviderProps, ResolvedBooking, useBookingKit } from "./Provider.mjs";
11
11
  import { BOOKING_NAVIGATION_EVENT, BookingLink, BookingLinkProps, BookingPendingHost, PENDING_DELAY_MS, PENDING_TIMEOUT_MS, bookingPrefetch, bookingTarget } from "./BookingLink.mjs";
12
- import { BookingConfirmation, BookingController, BookingSeed, BookingSuggestion, ConfirmedLine, UseBookingOptions, confirmationLines, isCompleteConfirmation, personForChild, useBooking } from "./useBooking.mjs";
12
+ import { BookingConfirmation, BookingController, BookingReadEndpoint, BookingReadError, BookingSeed, BookingSuggestion, ConfirmedLine, UseBookingOptions, confirmationLines, isCompleteConfirmation, personForChild, useBooking } from "./useBooking.mjs";
13
13
  import { BookingWizard, BookingWizardEvent, BookingWizardProps } from "./BookingWizard.mjs";
14
14
  import { LoginSheet, LoginSheetProps, VippsConfirm } from "./LoginSheet.mjs";
15
15
  import { LoginFromQuery, LoginFromQueryProps, VippsNotice, returnPathFromSearch, vippsNotice } from "./LoginPageQuery.mjs";
@@ -25,4 +25,4 @@ import { SESSION_TOUCH_THROTTLE_MS, SessionTouch, sessionTouchKey } from "./port
25
25
  import { VIPPS_LINK_FLASHES, VippsLinkFlash, vippsFlashPath, vippsLinkFlash } from "./portal/vipps-flash.mjs";
26
26
  import "./PortalDashboard.mjs";
27
27
  import { nextFreeClient, useNextFree } from "./use-next-free.mjs";
28
- export { ActionAnswer, ActionFailure, BOOKING_NAVIGATION_EVENT, BookingClassNames, BookingComponents, BookingConfirmation, BookingController, BookingKit, type BookingLabels, type BookingLabelsInput, BookingLink, BookingLinkProps, BookingOverrides, BookingPendingHost, BookingProvider, BookingProviderProps, BookingSeed, BookingSuggestion, BookingWizard, BookingWizardEvent, BookingWizardProps, ConfirmedLine, DEFAULT_PORTAL_TAB, DEFAULT_PORTAL_TAB_PARAM, DEFAULT_PORTAL_TAB_SLUGS, InvalidFailure, LoginFromQuery, LoginFromQuery as LoginPageQuery, LoginFromQueryProps, type LoginLabels, LoginSheet, LoginSheetProps, ManageBooking, ManageBookingProps, type ManageLabels, type NextFree, PENDING_DELAY_MS, PENDING_TIMEOUT_MS, PORTAL_TABS, PersonActionInput, PersonActionResult, PersonActionTarget, PortalActions, PortalDashboard, type PortalDashboardProps, PortalDashboardUnreachable, type PortalDashboardUnreachableProps, type PortalLabels, type PortalTab, type PortalTabIcons, ProfileActionResult, ResolvedBooking, SESSION_TOUCH_THROTTLE_MS, SafeActionEnvelope, type ScreenLabels, SessionFailure, SessionTouch, ThrottledFailure, UseBookingOptions, VIPPS_LINK_FLASHES, type VippsConfirm, type VippsLinkFlash, VippsNotice, type WizardLabels, agePromptCookie, agePromptCookieName, bookAgainHref, bookingPrefetch, bookingTarget, confirmationLines, createBookingKit, createChildSummaries, isCompleteConfirmation, leavePortal, nextFreeClient, parseAgePromptDismissed, parsePortalTab, personForChild, readAction, readStartLogin, returnPathFromSearch, sessionTouchKey, useBooking, useBookingKit, useNextFree, vippsFlashPath, vippsLinkFlash, vippsNotice };
28
+ export { ActionAnswer, ActionFailure, BOOKING_NAVIGATION_EVENT, BookingClassNames, BookingComponents, BookingConfirmation, BookingController, BookingKit, type BookingLabels, type BookingLabelsInput, BookingLink, BookingLinkProps, BookingOverrides, BookingPendingHost, BookingProvider, BookingProviderProps, BookingReadEndpoint, BookingReadError, BookingSeed, BookingSuggestion, BookingWizard, BookingWizardEvent, BookingWizardProps, ConfirmedLine, DEFAULT_PORTAL_TAB, DEFAULT_PORTAL_TAB_PARAM, DEFAULT_PORTAL_TAB_SLUGS, InvalidFailure, LoginFromQuery, LoginFromQuery as LoginPageQuery, LoginFromQueryProps, type LoginLabels, LoginSheet, LoginSheetProps, ManageBooking, ManageBookingProps, type ManageLabels, type NextFree, PENDING_DELAY_MS, PENDING_TIMEOUT_MS, PORTAL_TABS, PersonActionInput, PersonActionResult, PersonActionTarget, PortalActions, PortalDashboard, type PortalDashboardProps, PortalDashboardUnreachable, type PortalDashboardUnreachableProps, type PortalLabels, type PortalTab, type PortalTabIcons, ProfileActionResult, ResolvedBooking, SESSION_TOUCH_THROTTLE_MS, SafeActionEnvelope, type ScreenLabels, SessionFailure, SessionTouch, ThrottledFailure, UseBookingOptions, VIPPS_LINK_FLASHES, type VippsConfirm, type VippsLinkFlash, VippsNotice, type WizardLabels, agePromptCookie, agePromptCookieName, bookAgainHref, bookingPrefetch, bookingTarget, confirmationLines, createBookingKit, createChildSummaries, isCompleteConfirmation, leavePortal, nextFreeClient, parseAgePromptDismissed, parsePortalTab, personForChild, readAction, readStartLogin, returnPathFromSearch, sessionTouchKey, useBooking, useBookingKit, useNextFree, vippsFlashPath, vippsLinkFlash, vippsNotice };
@@ -67,6 +67,15 @@ export interface BookingConfirmation {
67
67
  }>;
68
68
  submitted: SubmittedVisit;
69
69
  }
70
+ /** The three reads the time step waits on. */
71
+ export type BookingReadEndpoint = 'availability' | 'resources' | 'schedule';
72
+ /** A read the wizard could not use: its endpoint, and the HTTP status (`null`: no answer at all). */
73
+ export interface BookingReadError {
74
+ endpoint: BookingReadEndpoint;
75
+ status: number | null;
76
+ /** Bumped per failure, so two identical failures are two events. */
77
+ seq: number;
78
+ }
70
79
  /**
71
80
  * Whether a create answer is a whole one: exactly one booking, with its own
72
81
  * distinct id, per submitted line. Anything else — none, fewer, more, an id
@@ -183,8 +192,9 @@ export declare function useBooking(options: UseBookingOptions): {
183
192
  submissionNonce: string | undefined;
184
193
  confirmed: BookingConfirmation | null;
185
194
  startOver: () => void;
186
- /** Ask for the openings again after a failure. */
187
195
  refresh: () => void;
196
+ /** The recent reads that failed, oldest first (`onEvent`'s `read_error`). */
197
+ readErrors: readonly BookingReadError[];
188
198
  restore: {
189
199
  hydrated: boolean;
190
200
  restoring: boolean;