@medalsocial/meda 2.8.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +130 -1
  2. package/dist/booking/add-child-sheet.d.ts +38 -0
  3. package/dist/booking/add-child-sheet.js +88 -0
  4. package/dist/booking/booking-skeleton.d.ts +24 -0
  5. package/dist/booking/booking-skeleton.js +17 -0
  6. package/dist/booking/confirmation.d.ts +85 -0
  7. package/dist/booking/confirmation.js +102 -0
  8. package/dist/booking/details-screen.d.ts +118 -0
  9. package/dist/booking/details-screen.js +314 -0
  10. package/dist/booking/format.d.ts +64 -0
  11. package/dist/booking/format.js +1 -0
  12. package/dist/booking/index.d.ts +35 -0
  13. package/dist/booking/index.js +40 -0
  14. package/dist/booking/internal/label-parts.d.ts +8 -0
  15. package/dist/booking/internal/label-parts.js +19 -0
  16. package/dist/booking/internal/ui.d.ts +33 -0
  17. package/dist/booking/internal/ui.js +35 -0
  18. package/dist/booking/label-keys.d.ts +34 -0
  19. package/dist/booking/label-keys.js +56 -0
  20. package/dist/booking/labels.d.ts +15 -0
  21. package/dist/booking/labels.js +17 -0
  22. package/dist/booking/live-status.d.ts +14 -0
  23. package/dist/booking/live-status.js +17 -0
  24. package/dist/booking/login-panel.d.ts +108 -0
  25. package/dist/booking/login-panel.js +239 -0
  26. package/dist/booking/login-sheet.d.ts +41 -0
  27. package/dist/booking/login-sheet.js +49 -0
  28. package/dist/booking/manage-screen.d.ts +113 -0
  29. package/dist/booking/manage-screen.js +369 -0
  30. package/dist/booking/otp-slots.d.ts +42 -0
  31. package/dist/booking/otp-slots.js +109 -0
  32. package/dist/booking/portal/account-card.d.ts +24 -0
  33. package/dist/booking/portal/account-card.js +18 -0
  34. package/dist/booking/portal/action-result.d.ts +30 -0
  35. package/dist/booking/portal/action-result.js +18 -0
  36. package/dist/booking/portal/age-confirm-card.d.ts +39 -0
  37. package/dist/booking/portal/age-confirm-card.js +42 -0
  38. package/dist/booking/portal/child-cards.d.ts +65 -0
  39. package/dist/booking/portal/child-cards.js +59 -0
  40. package/dist/booking/portal/data-controls.d.ts +48 -0
  41. package/dist/booking/portal/data-controls.js +103 -0
  42. package/dist/booking/portal/family-editor.d.ts +141 -0
  43. package/dist/booking/portal/family-editor.js +422 -0
  44. package/dist/booking/portal/logout-button.d.ts +21 -0
  45. package/dist/booking/portal/logout-button.js +27 -0
  46. package/dist/booking/portal/portal-shell.d.ts +71 -0
  47. package/dist/booking/portal/portal-shell.js +38 -0
  48. package/dist/booking/portal/portal-unreachable.d.ts +31 -0
  49. package/dist/booking/portal/portal-unreachable.js +19 -0
  50. package/dist/booking/portal/profile-form.d.ts +75 -0
  51. package/dist/booking/portal/profile-form.js +144 -0
  52. package/dist/booking/portal/rebook-cards.d.ts +44 -0
  53. package/dist/booking/portal/rebook-cards.js +35 -0
  54. package/dist/booking/portal/upcoming-bookings.d.ts +65 -0
  55. package/dist/booking/portal/upcoming-bookings.js +88 -0
  56. package/dist/booking/portal/vipps-link-row.d.ts +54 -0
  57. package/dist/booking/portal/vipps-link-row.js +80 -0
  58. package/dist/booking/portal/visit-history.d.ts +63 -0
  59. package/dist/booking/portal/visit-history.js +73 -0
  60. package/dist/booking/service-screen.d.ts +137 -0
  61. package/dist/booking/service-screen.js +233 -0
  62. package/dist/booking/slots.d.ts +12 -0
  63. package/dist/booking/slots.js +5 -0
  64. package/dist/booking/stylist-screen.d.ts +117 -0
  65. package/dist/booking/stylist-screen.js +226 -0
  66. package/dist/booking/summary-bar.d.ts +26 -0
  67. package/dist/booking/summary-bar.js +32 -0
  68. package/dist/booking/time-screen.d.ts +191 -0
  69. package/dist/booking/time-screen.js +418 -0
  70. package/dist/booking/types.d.ts +284 -0
  71. package/dist/booking/types.js +15 -0
  72. package/dist/booking/vipps-button.d.ts +49 -0
  73. package/dist/booking/vipps-button.js +33 -0
  74. package/dist/booking/who-screen.d.ts +121 -0
  75. package/dist/booking/who-screen.js +164 -0
  76. package/dist/primitives/avatar.d.ts +24 -0
  77. package/dist/primitives/avatar.js +44 -0
  78. package/dist/primitives/button.d.ts +21 -0
  79. package/dist/primitives/button.js +51 -0
  80. package/dist/primitives/checkbox.d.ts +23 -0
  81. package/dist/primitives/checkbox.js +20 -0
  82. package/dist/primitives/field.d.ts +47 -0
  83. package/dist/primitives/field.js +48 -0
  84. package/dist/primitives/index.d.ts +16 -0
  85. package/dist/primitives/index.js +8 -0
  86. package/dist/primitives/input.d.ts +10 -0
  87. package/dist/primitives/input.js +9 -0
  88. package/dist/primitives/sheet.d.ts +68 -0
  89. package/dist/primitives/sheet.js +303 -0
  90. package/dist/primitives/textarea.d.ts +14 -0
  91. package/dist/primitives/textarea.js +13 -0
  92. package/dist/primitives/toggle-group.d.ts +46 -0
  93. package/dist/primitives/toggle-group.js +148 -0
  94. package/dist/styles/base.css +40 -0
  95. package/dist/styles/booking.css +10 -0
  96. package/dist/styles/bridge.css +159 -0
  97. package/dist/styles/calendar.css +8 -0
  98. package/dist/styles/primitives.css +10 -0
  99. package/dist/styles/theme.css +12 -162
  100. package/dist/styles/vendor/xyflow.css +626 -0
  101. package/dist/styles/workflow-builder.css +17 -0
  102. package/package.json +45 -9
  103. package/skills/components/SKILL.md +53 -0
@@ -0,0 +1,314 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId, useRef, useState } from 'react';
4
+ import { Checkbox } from '../primitives/checkbox.js';
5
+ import { Field } from '../primitives/field.js';
6
+ import { Input } from '../primitives/input.js';
7
+ import { Textarea } from '../primitives/textarea.js';
8
+ import { BOOKING_CHECKBOX_CLASS, BOOKING_INPUT_CLASS, BOOKING_TEXTAREA_CLASS, BookingButton, } from './internal/ui.js';
9
+ import { fillLabel } from './labels.js';
10
+ import { slotClass } from './slots.js';
11
+ /**
12
+ * The last step of the booking wizard: contact details, who each seat is for,
13
+ * the consents, and the button that books.
14
+ *
15
+ * Presentational, with the same bargain the other steps make: every answer
16
+ * leaves as an action (`onChange`) and the caller's machine decides what it
17
+ * means. What stays here is local UI state that is not an answer about the
18
+ * booking — the keystrokes behind the per-child fields, which field has focus,
19
+ * whether submit has been pressed, and a fallback submission nonce.
20
+ */
21
+ // ---------------------------------------------------------------------------
22
+ // Labels
23
+ // ---------------------------------------------------------------------------
24
+ export const DETAILS_SCREEN_LABEL_KEYS = [
25
+ 'details.heading',
26
+ 'details.phone.label',
27
+ /** The country code shown (not typed) before the number. Empty string hides it. */
28
+ 'details.phone.prefix',
29
+ 'details.phone.error',
30
+ 'details.name.label',
31
+ /** Accessible name of the list of seats step 1 already named. */
32
+ 'details.known.label',
33
+ /** `{who}` follows the bold service name: «for {who}». */
34
+ 'details.known.for',
35
+ 'details.known.self',
36
+ 'details.known.adult',
37
+ 'details.known.child',
38
+ 'details.child.name',
39
+ 'details.child.year',
40
+ 'details.child.yearPlaceholder',
41
+ 'details.email.label',
42
+ 'details.email.help',
43
+ 'details.email.error',
44
+ 'details.notes.label',
45
+ 'details.notes.placeholder',
46
+ 'details.terms.text',
47
+ /** Link text after the terms sentence; only drawn when `termsHref` is set. */
48
+ 'details.terms.link',
49
+ 'details.marketing.text',
50
+ 'details.error.maxParty',
51
+ 'details.error.slotTaken',
52
+ 'details.error.conflict',
53
+ 'details.error.invalidInput',
54
+ 'details.error.unconfigured',
55
+ 'details.error.upstreamError',
56
+ 'details.error.inProgress',
57
+ /** `{phone}` — visible text and accessible name of the call link. */
58
+ 'details.call.link',
59
+ /** The rescue sentence when there is no phone number to link. */
60
+ 'details.call.none',
61
+ /** `{price}` — the total, already formatted. */
62
+ 'details.submit',
63
+ ];
64
+ /**
65
+ * The label key for each failure the wizard can carry. Exported because some
66
+ * of them (e.g. `maxParty`) are raised on earlier steps, where the shell shows
67
+ * the same sentence — one map, so one refusal is never explained two ways.
68
+ */
69
+ export const DETAILS_ERROR_LABEL_KEYS = {
70
+ maxParty: 'details.error.maxParty',
71
+ slotTaken: 'details.error.slotTaken',
72
+ conflict: 'details.error.conflict',
73
+ invalidInput: 'details.error.invalidInput',
74
+ unconfigured: 'details.error.unconfigured',
75
+ upstreamError: 'details.error.upstreamError',
76
+ inProgress: 'details.error.inProgress',
77
+ };
78
+ /** One saved child offered as a single tap above the child fields. */
79
+ export function DefaultFamilyChip({ member, pressed, onPick }) {
80
+ return (_jsx(BookingButton, { variant: "outline", size: "sm", "aria-pressed": pressed, className: "rounded-full", onClick: onPick, children: member.name }));
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ // Helpers
84
+ // ---------------------------------------------------------------------------
85
+ /**
86
+ * Deliberately loose: one `@`, something on each side, a dot in the domain,
87
+ * no spaces. The field is optional, so the only job is to catch an address
88
+ * that cannot receive anything. Not RFC 5322 — a stricter pattern rejects real
89
+ * addresses.
90
+ */
91
+ /**
92
+ * A random submission id. `crypto.randomUUID` only exists in secure contexts
93
+ * (https, localhost); a plain-http origin falls back to `getRandomValues`,
94
+ * which is available everywhere, shaped as a v4 UUID.
95
+ */
96
+ function mintNonce() {
97
+ if (typeof crypto.randomUUID === 'function')
98
+ return crypto.randomUUID();
99
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
100
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
101
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
102
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
103
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
104
+ }
105
+ /** Blank is fine (the field is optional). Anything else has to be reachable. */
106
+ export function looksLikeEmail(value) {
107
+ const trimmed = value.trim();
108
+ if (trimmed.length === 0)
109
+ return true;
110
+ // Hand-rolled rather than a regex: linear on any input, same rule as
111
+ // /^[^\s@]+@[^\s@]+\.[^\s@]+$/.
112
+ if (/\s/.test(trimmed))
113
+ return false;
114
+ const at = trimmed.indexOf('@');
115
+ if (at < 1 || at !== trimmed.lastIndexOf('@'))
116
+ return false;
117
+ const domain = trimmed.slice(at + 1);
118
+ for (let index = 1; index < domain.length - 1; index += 1) {
119
+ if (domain[index] === '.')
120
+ return true;
121
+ }
122
+ return false;
123
+ }
124
+ const NO_CHILD = { name: '', birthYear: '' };
125
+ function seedChildren(items) {
126
+ return Object.fromEntries(items.map((item, index) => [
127
+ index,
128
+ {
129
+ name: item.bookedForName ?? '',
130
+ birthYear: item.bookedForBirthYear === undefined ? '' : String(item.bookedForBirthYear),
131
+ },
132
+ ]));
133
+ }
134
+ /** Four digits and nothing else — `parseInt` would accept a numeric prefix. */
135
+ const FOUR_DIGITS = /^\d{4}$/;
136
+ const EARLIEST_BIRTH_YEAR = 1900;
137
+ const LATEST_BIRTH_YEAR = 2200;
138
+ /** The year as a number, or `null` for «nothing usable typed». */
139
+ function parseBirthYear(raw) {
140
+ const trimmed = raw.trim();
141
+ if (!FOUR_DIGITS.test(trimmed))
142
+ return null;
143
+ const year = Number(trimmed);
144
+ return year >= EARLIEST_BIRTH_YEAR && year <= LATEST_BIRTH_YEAR ? year : null;
145
+ }
146
+ /** Spread into an object literal, so «nothing typed» is an absent key, not `''`. */
147
+ function present(key, value) {
148
+ const trimmed = value.trim();
149
+ return trimmed.length > 0 ? { [key]: trimmed } : undefined;
150
+ }
151
+ /** Who a seat step 1 already named is for, in words. */
152
+ function knownWho(state, index, labels) {
153
+ const item = state.items[index];
154
+ if (item?.adult) {
155
+ return state.people[index]?.key === 'self'
156
+ ? labels['details.known.self']
157
+ : labels['details.known.adult'];
158
+ }
159
+ return item?.bookedForName ?? labels['details.known.child'];
160
+ }
161
+ /**
162
+ * Whether step 1 already said who sits in this seat: a saved person's card
163
+ * (`p:` / `n:`), the logged-in guardian (`self`) or a guest adult (`adult`).
164
+ */
165
+ function isKnownChair(state, index) {
166
+ const key = state.people[index]?.key ?? '';
167
+ return key.startsWith('p:') || key.startsWith('n:') || key === 'self' || key === 'adult';
168
+ }
169
+ // ---------------------------------------------------------------------------
170
+ // Screen
171
+ // ---------------------------------------------------------------------------
172
+ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phoneLooksValid, normalisePhone, submitting = false, phone = null, submissionNonce: nonceProp, family, guardianPhone = null, termsHref = null, marketingConsent = false, format, labels, classNames, components, }) {
173
+ const ids = useId();
174
+ const field = (name) => `${ids}-${name}`;
175
+ const phoneRef = useRef(null);
176
+ const emailRef = useRef(null);
177
+ const termsRef = useRef(null);
178
+ // The text on the way to the machine's answers. Seeded from the items so a
179
+ // visitor who steps back and returns finds the names still in the boxes;
180
+ // keyed by index because two lines can share a service.
181
+ const [children, setChildren] = useState(() => seedChildren(state.items));
182
+ const childAt = (index) => children[index] ?? NO_CHILD;
183
+ // Minted once per mount (a lazy initialiser), never per press: a
184
+ // double-tapped submit must carry the same nonce twice.
185
+ // Only minted when the caller does not pass one (lazily, once per mount).
186
+ const ownNonce = useRef(null);
187
+ if (nonceProp === undefined && ownNonce.current === null)
188
+ ownNonce.current = mintNonce();
189
+ const submissionNonce = nonceProp ?? ownNonce.current ?? '';
190
+ // Which field has focus: the phone error is held back while the visitor is
191
+ // still typing and appears the moment the field is left.
192
+ const [focused, setFocused] = useState(null);
193
+ // Submit is never disabled for validation, so pressing it is what explains a refusal.
194
+ const [attempted, setAttempted] = useState(false);
195
+ const phoneTyped = state.contact.phone.trim().length > 0;
196
+ const phoneValid = phoneLooksValid(state.contact.phone);
197
+ const emailValid = looksLikeEmail(state.contact.email);
198
+ const showPhoneError = !phoneValid && (attempted || (phoneTyped && focused !== 'phone'));
199
+ const showEmailError = !emailValid && (attempted || focused !== 'email');
200
+ const showTermsError = attempted && !state.consentTerms;
201
+ const price = format.price(totalOre);
202
+ const phonePrefix = labels['details.phone.prefix'];
203
+ const FamilyChip = components?.FamilyChip ?? DefaultFamilyChip;
204
+ function setContact(name, value) {
205
+ onChange({ type: 'setContact', field: name, value });
206
+ }
207
+ /** Written to the buffer (what the field shows) and to the machine (the answer). */
208
+ function setChild(index, patch, personId) {
209
+ setChildren((previous) => ({
210
+ ...previous,
211
+ [index]: { ...(previous[index] ?? NO_CHILD), ...patch },
212
+ }));
213
+ if (patch.name !== undefined) {
214
+ onChange({ type: 'setItemField', index, field: 'bookedForName', value: patch.name });
215
+ // A family chip names a saved person; a name typed by hand may be someone
216
+ // the business does not know.
217
+ onChange({
218
+ type: 'setItemField',
219
+ index,
220
+ field: 'bookedForPersonId',
221
+ value: personId ?? null,
222
+ });
223
+ }
224
+ if (patch.birthYear !== undefined) {
225
+ onChange({
226
+ type: 'setItemField',
227
+ index,
228
+ field: 'bookedForBirthYear',
229
+ value: parseBirthYear(patch.birthYear),
230
+ });
231
+ }
232
+ }
233
+ function submissionItems(startTs) {
234
+ // A saved person's id is only valid under the number of the guardian who owns them.
235
+ const ownPhone = guardianPhone !== null &&
236
+ normalisePhone(guardianPhone) === normalisePhone(state.contact.phone);
237
+ return state.items.map((item, index) => {
238
+ const child = childAt(index);
239
+ const birthYear = parseBirthYear(child.birthYear);
240
+ const line = lines[index];
241
+ const resourceId = line?.resourceId ?? null;
242
+ return {
243
+ serviceId: item.service.id,
244
+ ...(resourceId === null ? {} : { resourceId }),
245
+ startTs: line?.startTs ?? startTs,
246
+ ...present('bookedForName', isKnownChair(state, index) ? (item.bookedForName ?? '') : child.name),
247
+ ...(birthYear === null ? {} : { bookedForBirthYear: birthYear }),
248
+ ...(ownPhone && item.bookedForPersonId
249
+ ? { bookedForPersonId: item.bookedForPersonId }
250
+ : {}),
251
+ };
252
+ });
253
+ }
254
+ function handleSubmit() {
255
+ setAttempted(true);
256
+ // Unreachable through the wizard, but submitting without a slot would book 1970.
257
+ if (state.startTs === null)
258
+ return;
259
+ // Focus rather than scroll: it moves the viewport, puts the caret where the
260
+ // fix is needed, and works for whoever is not looking at the screen.
261
+ if (!phoneValid) {
262
+ phoneRef.current?.focus();
263
+ return;
264
+ }
265
+ if (!emailValid) {
266
+ emailRef.current?.focus();
267
+ return;
268
+ }
269
+ if (!state.consentTerms) {
270
+ termsRef.current?.focus();
271
+ return;
272
+ }
273
+ onSubmit({
274
+ items: submissionItems(state.startTs),
275
+ contact: {
276
+ phone: normalisePhone(state.contact.phone),
277
+ ...present('name', state.contact.name),
278
+ ...present('email', state.contact.email),
279
+ },
280
+ ...present('notes', state.notes),
281
+ consentTerms: state.consentTerms,
282
+ consentMarketing: state.consentMarketing,
283
+ submissionNonce,
284
+ });
285
+ }
286
+ const inputClass = (...extra) => slotClass(classNames, 'input', BOOKING_INPUT_CLASS, ...extra);
287
+ const fieldClass = slotClass(classNames, 'field');
288
+ return (_jsxs("section", { "aria-labelledby": "booking-details-heading", className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h2", { id: "booking-details-heading", tabIndex: -1, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: labels['details.heading'] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('phone'), children: labels['details.phone.label'] }), _jsxs("div", { className: "flex items-stretch", children: [phonePrefix && (_jsx("span", { id: field('phone-prefix'), className: "flex h-12 items-center rounded-l-[4px] border border-r-0 border-input bg-muted px-4 text-base text-muted-foreground", children: phonePrefix })), _jsx(Input, { id: field('phone'), ref: phoneRef, type: "tel", inputMode: "numeric", autoComplete: "tel-national", className: inputClass(phonePrefix ? 'rounded-l-none' : ''), value: state.contact.phone, "aria-describedby": phonePrefix ? field('phone-prefix') : undefined, "aria-invalid": showPhoneError || undefined, onFocus: () => setFocused('phone'), onBlur: () => setFocused(null), onChange: (event) => setContact('phone', event.target.value) })] }), showPhoneError && _jsx(Field.Error, { children: labels['details.phone.error'] })] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('name'), children: labels['details.name.label'] }), _jsx(Input, { id: field('name'), autoComplete: "name", className: inputClass(), value: state.contact.name, onChange: (event) => setContact('name', event.target.value) })] }), state.items.some((_, index) => isKnownChair(state, index)) && (_jsx("ul", { "aria-label": labels['details.known.label'], className: "space-y-1 text-sm", children: state.items.map((item, index) => isKnownChair(state, index) ? (_jsxs("li", { children: [_jsx("span", { className: "font-semibold", children: item.service.name }), ' ', fillLabel(labels['details.known.for'], { who: knownWho(state, index, labels) })] }, index)) : null) })), state.items.map((item, index) =>
289
+ // A seat step 1 already named is not asked again: a second answer could disagree.
290
+ isKnownChair(state, index) ? null : (_jsx(ChildFields
291
+ // biome-ignore lint/suspicious/noArrayIndexKey: two lines can share a service; the index is the identity
292
+ , { legend: state.items.length > 1 ? item.service.name : null, nameId: field(`child-name-${index}`), yearId: field(`child-year-${index}`), value: childAt(index), family: family, labels: labels, classNames: classNames, FamilyChip: FamilyChip, onChange: (patch, personId) => setChild(index, patch, personId) }, index))), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('email'), children: labels['details.email.label'] }), _jsx(Input, { id: field('email'), ref: emailRef, type: "email", autoComplete: "email", className: inputClass(), value: state.contact.email, "aria-describedby": field('email-help'), "aria-invalid": showEmailError || undefined, onFocus: () => setFocused('email'), onBlur: () => setFocused(null), onChange: (event) => setContact('email', event.target.value) }), _jsx(Field.Description, { id: field('email-help'), children: labels['details.email.help'] }), showEmailError && _jsx(Field.Error, { children: labels['details.email.error'] })] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('notes'), children: labels['details.notes.label'] }), _jsx(Textarea, { id: field('notes'), rows: 3, className: slotClass(classNames, 'input', BOOKING_TEXTAREA_CLASS), placeholder: labels['details.notes.placeholder'], value: state.notes, onChange: (event) => onChange({ type: 'setNotes', value: event.target.value }) })] }), _jsxs(Field, { orientation: "horizontal", className: slotClass(classNames, 'consent'), children: [_jsx(Checkbox, { id: field('terms'), ref: termsRef, className: BOOKING_CHECKBOX_CLASS, checked: state.consentTerms, "aria-invalid": showTermsError || undefined, onCheckedChange: (accepted) => onChange({ type: 'setConsent', which: 'terms', accepted }) }), _jsx(Field.Label, { htmlFor: field('terms'), className: "cursor-pointer font-normal", children: labels['details.terms.text'] }), termsHref && (_jsx("a", { href: termsHref, className: "col-start-2 text-sm font-semibold text-primary underline underline-offset-4", children: labels['details.terms.link'] }))] }), marketingConsent && (_jsxs(Field, { orientation: "horizontal", className: slotClass(classNames, 'consent'), children: [_jsx(Checkbox, { id: field('marketing'), className: BOOKING_CHECKBOX_CLASS, checked: state.consentMarketing, onCheckedChange: (accepted) => onChange({ type: 'setConsent', which: 'marketing', accepted }) }), _jsx(Field.Label, { htmlFor: field('marketing'), className: "cursor-pointer font-normal", children: labels['details.marketing.text'] })] })), state.error && (_jsx(SubmitFailure, { error: state.error, phone: phone, format: format, labels: labels, className: slotClass(classNames, 'error', 'rounded-lg border border-destructive/40 bg-destructive/10 px-5 py-3 text-sm') })), _jsx(BookingButton, { size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: submitting, "aria-busy": submitting || undefined, onClick: handleSubmit, children: fillLabel(labels['details.submit'], { price }) })] }));
293
+ }
294
+ /**
295
+ * The name and birth year for one line. Wrapped in a fieldset only when there
296
+ * is more than one line: two identically labelled fields with nothing between
297
+ * them is how one child's name lands on the other's booking.
298
+ */
299
+ function ChildFields({ legend, nameId, yearId, value, family, labels, classNames, FamilyChip, onChange, }) {
300
+ const fields = (_jsxs("div", { className: "grid grid-cols-[1fr_7rem] gap-3", children: [_jsxs(Field, { className: slotClass(classNames, 'field'), children: [_jsx(Field.Label, { htmlFor: nameId, children: labels['details.child.name'] }), _jsx(Input, { id: nameId, className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS), value: value.name, onChange: (event) => onChange({ name: event.target.value }) })] }), _jsxs(Field, { className: slotClass(classNames, 'field'), children: [_jsx(Field.Label, { htmlFor: yearId, children: labels['details.child.year'] }), _jsx(Input, { id: yearId, inputMode: "numeric", maxLength: 4, className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS), placeholder: labels['details.child.yearPlaceholder'], value: value.birthYear, onChange: (event) => onChange({ birthYear: event.target.value }) })] })] }));
301
+ const withSuggestions = (_jsxs("div", { className: "space-y-3", children: [family !== undefined && family.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: family.map((member) => (_jsx(FamilyChip, { member: member, pressed: value.name === member.name && value.birthYear === String(member.birthYear), onPick: () => onChange({ name: member.name, birthYear: String(member.birthYear) }, member.personId) }, `${member.name}-${member.birthYear}`))) })), fields] }));
302
+ if (legend === null)
303
+ return withSuggestions;
304
+ return (_jsxs("fieldset", { className: slotClass(classNames, 'childGroup', 'space-y-2 rounded-lg border border-border px-5 py-4'), children: [_jsx("legend", { className: "px-1 font-sans text-sm font-bold text-muted-foreground", children: legend }), withSuggestions] }));
305
+ }
306
+ /**
307
+ * A failed submit must not be silent: announced (nothing else on the screen
308
+ * moved), and it offers the phone, because what reaches here is what the
309
+ * visitor cannot fix by trying harder.
310
+ */
311
+ function SubmitFailure({ error, phone, format, labels, className, }) {
312
+ const call = phone ? fillLabel(labels['details.call.link'], { phone }) : null;
313
+ return (_jsxs("p", { role: "alert", className: className, children: [labels[DETAILS_ERROR_LABEL_KEYS[error]], ' ', phone && call ? (_jsx("a", { href: format.telHref(phone), "aria-label": call, className: "font-semibold text-primary underline underline-offset-4", children: call })) : (labels['details.call.none'])] }));
314
+ }
@@ -0,0 +1,64 @@
1
+ import type { AgeRange } from './types.js';
2
+ /**
3
+ * The business's calendar, injected.
4
+ *
5
+ * Every date a booking screen draws is a date *where the business is*, not
6
+ * where the visitor's browser is — so the screens never call `Date` getters or
7
+ * `Intl` themselves. The booking package builds one of these per config
8
+ * (`createClock({ timeZone, locale, dayparts })`) and passes it in; a test or a
9
+ * story can hand in any implementation with the same shape.
10
+ */
11
+ export interface BookingClock {
12
+ /** `YYYY-MM-DD` of the business-local date `ts` falls on. */
13
+ dayKey(ts: number): string;
14
+ /** An instant at the start of the business-local day `offsetDays` after `ts`'s. DST-safe. */
15
+ dayStart(ts: number, offsetDays?: number): number;
16
+ /** «14:30». */
17
+ formatTime(ts: number): string;
18
+ /** Business-local hour 0–23. */
19
+ hour(ts: number): number;
20
+ isWeekend(ts: number): boolean;
21
+ /** Whole business-local calendar days from `from` to `to` (can be negative). */
22
+ daysBetween(from: number, to: number): number;
23
+ /** Key of the daypart `ts` falls in (e.g. `morning`). */
24
+ daypartOf(ts: number): string;
25
+ /** «today» / «tomorrow» / «Thursday 3 September». */
26
+ dayLabel(ts: number, now?: number): string;
27
+ /** The short chip form: «Today», «Thu 3». */
28
+ dayChip(ts: number, now?: number): string;
29
+ /** «today 14:30» / «Thu 3 Sep 14:30». */
30
+ when(ts: number, now?: number): string;
31
+ /** The short date with weekday: «tor. 3. sep.» / «Thu 3 Sept». */
32
+ date(ts: number): string;
33
+ /** `date` plus the time: «tor. 3. sep. kl. 14:30». */
34
+ dateTime(ts: number): string;
35
+ /** «Thursday». */
36
+ weekday(ts: number): string;
37
+ /** Month name, 1–12, lower-case where the locale writes it so («september»). */
38
+ monthName(month: number): string;
39
+ /** Seven short weekday heads, Monday first («Mo» … «Su»). */
40
+ weekdayHeads(): readonly string[];
41
+ }
42
+ /**
43
+ * Everything locale- or business-dependent a screen formats, in one object.
44
+ * Screens take it as their `format` prop. Nothing here is copy: the words come
45
+ * through `labels`.
46
+ */
47
+ export interface BookingFormat {
48
+ clock: BookingClock;
49
+ /** Minor units → display, e.g. `186000` → «1 860 kr». */
50
+ price(minor: number): string;
51
+ /** A `tel:` href for a phone number as written. */
52
+ telHref(phone: string): string;
53
+ /** A stylist's name as customers should see it (trimmed of internal suffixes). */
54
+ stylistName(name: string): string;
55
+ /** One or two initials for an avatar fallback. */
56
+ initials(name: string): string;
57
+ /** «7 år» / «6–7 years». */
58
+ ageLabel(range: AgeRange): string;
59
+ }
60
+ /** A named part of the day for the time step, in display order (`clock.daypartOf` returns the key). */
61
+ export interface BookingDaypart {
62
+ key: string;
63
+ label: string;
64
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ export * from './add-child-sheet.js';
2
+ export * from './booking-skeleton.js';
3
+ export * from './confirmation.js';
4
+ export * from './details-screen.js';
5
+ export type { BookingClock, BookingDaypart, BookingFormat } from './format.js';
6
+ export * from './internal/ui.js';
7
+ export * from './label-keys.js';
8
+ export { fillLabel } from './labels.js';
9
+ export * from './live-status.js';
10
+ export * from './login-panel.js';
11
+ export * from './login-sheet.js';
12
+ export * from './manage-screen.js';
13
+ export * from './otp-slots.js';
14
+ export * from './portal/account-card.js';
15
+ export * from './portal/action-result.js';
16
+ export * from './portal/age-confirm-card.js';
17
+ export * from './portal/child-cards.js';
18
+ export * from './portal/data-controls.js';
19
+ export * from './portal/family-editor.js';
20
+ export * from './portal/logout-button.js';
21
+ export * from './portal/portal-shell.js';
22
+ export * from './portal/portal-unreachable.js';
23
+ export * from './portal/profile-form.js';
24
+ export * from './portal/rebook-cards.js';
25
+ export * from './portal/upcoming-bookings.js';
26
+ export * from './portal/vipps-link-row.js';
27
+ export * from './portal/visit-history.js';
28
+ export * from './service-screen.js';
29
+ export type { ScreenComponents, SlotClassNames } from './slots.js';
30
+ export * from './stylist-screen.js';
31
+ export * from './summary-bar.js';
32
+ export * from './time-screen.js';
33
+ export type * from './types.js';
34
+ export * from './vipps-button.js';
35
+ export * from './who-screen.js';
@@ -0,0 +1,40 @@
1
+ // @medalsocial/meda/booking — unbranded, presentational booking screens.
2
+ //
3
+ // Props in, callbacks out: nothing here fetches, routes, or touches storage.
4
+ // Copy arrives through `labels`, dates/prices through `format`, look through
5
+ // the shadcn variables the theme bridge maps. Override ladder, cheapest first:
6
+ // CSS variables → labels → classNames (per slot) → components (card renderers).
7
+ // Styles: import `@medalsocial/meda/booking/styles.css` and
8
+ // `@medalsocial/meda/primitives/styles.css` after the bridge.
9
+ export * from './add-child-sheet.js';
10
+ export * from './booking-skeleton.js';
11
+ export * from './confirmation.js';
12
+ export * from './details-screen.js';
13
+ export * from './internal/ui.js';
14
+ export * from './label-keys.js';
15
+ export { fillLabel } from './labels.js';
16
+ export * from './live-status.js';
17
+ export * from './login-panel.js';
18
+ export * from './login-sheet.js';
19
+ export * from './manage-screen.js';
20
+ export * from './otp-slots.js';
21
+ export * from './portal/account-card.js';
22
+ export * from './portal/action-result.js';
23
+ export * from './portal/age-confirm-card.js';
24
+ export * from './portal/child-cards.js';
25
+ export * from './portal/data-controls.js';
26
+ export * from './portal/family-editor.js';
27
+ export * from './portal/logout-button.js';
28
+ export * from './portal/portal-shell.js';
29
+ export * from './portal/portal-unreachable.js';
30
+ export * from './portal/profile-form.js';
31
+ export * from './portal/rebook-cards.js';
32
+ export * from './portal/upcoming-bookings.js';
33
+ export * from './portal/vipps-link-row.js';
34
+ export * from './portal/visit-history.js';
35
+ export * from './service-screen.js';
36
+ export * from './stylist-screen.js';
37
+ export * from './summary-bar.js';
38
+ export * from './time-screen.js';
39
+ export * from './vipps-button.js';
40
+ export * from './who-screen.js';
@@ -0,0 +1,8 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * `fillLabel` for templates whose placeholders are elements (a link, a
4
+ * button) rather than strings: `'Have an account? {trigger} – …'` with
5
+ * `{ trigger: <button>…</button> }`. Text parts stay text; unknown
6
+ * placeholders are left as written.
7
+ */
8
+ export declare function labelParts(template: string, parts: Readonly<Record<string, ReactNode>>): ReactNode;
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Fragment } from 'react';
3
+ /**
4
+ * `fillLabel` for templates whose placeholders are elements (a link, a
5
+ * button) rather than strings: `'Have an account? {trigger} – …'` with
6
+ * `{ trigger: <button>…</button> }`. Text parts stay text; unknown
7
+ * placeholders are left as written.
8
+ */
9
+ export function labelParts(template, parts) {
10
+ const pieces = template.split(/(\{\w+\})/g);
11
+ return pieces.map((piece, index) => {
12
+ const match = /^\{(\w+)\}$/.exec(piece);
13
+ if (match && Object.hasOwn(parts, match[1])) {
14
+ // biome-ignore lint/suspicious/noArrayIndexKey: the pieces of a fixed template never reorder
15
+ return _jsx(Fragment, { children: parts[match[1]] }, index);
16
+ }
17
+ return piece;
18
+ });
19
+ }
@@ -0,0 +1,33 @@
1
+ import type { ComponentProps } from 'react';
2
+ /**
3
+ * The booking screens' control look, in one place.
4
+ *
5
+ * Booking has its own proportions — 48 px fields, 1.5 px borders, near-square
6
+ * corners, a tall primary button — tuned for a parent booking on a phone. The
7
+ * screens build on meda's primitives (`Input`, `Textarea`, `Checkbox`,
8
+ * `Field`, `Sheet`) and pass these classes as overrides; tailwind-merge makes
9
+ * them win over the primitive defaults they conflict with. Colours are bridge
10
+ * variables only, so a site's own `--primary` / `--border` / … re-theme them.
11
+ */
12
+ export type BookingButtonVariant = 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link';
13
+ export type BookingButtonSize = 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm';
14
+ /** Classes for a booking button — also for a link that should look like one. */
15
+ export declare function bookingButtonClass({ variant, size, className, }?: {
16
+ variant?: BookingButtonVariant;
17
+ size?: BookingButtonSize;
18
+ className?: string;
19
+ }): string;
20
+ export interface BookingButtonProps extends ComponentProps<'button'> {
21
+ variant?: BookingButtonVariant;
22
+ size?: BookingButtonSize;
23
+ }
24
+ /** The booking screens' button. A native `<button type="button">` unless told otherwise. */
25
+ export declare function BookingButton({ variant, size, className, type, ...props }: BookingButtonProps): import("react").JSX.Element;
26
+ /** Override for meda `Input` in booking forms. */
27
+ export declare const BOOKING_INPUT_CLASS = "h-12 rounded-[4px] border-[1.5px] border-input bg-background px-4 py-3 text-base transition-[color,border-color] focus-visible:border-primary focus-visible:ring-0 aria-invalid:border-destructive sm:text-base md:text-base dark:aria-invalid:border-destructive/50";
28
+ /** Override for meda `Textarea` in booking forms. */
29
+ export declare const BOOKING_TEXTAREA_CLASS = "min-h-16 rounded-[4px] border-[1.5px] border-input bg-background px-4 py-3 text-base transition-[color,border-color] focus-visible:border-primary focus-visible:ring-0 aria-invalid:border-destructive sm:text-base md:text-base dark:aria-invalid:border-destructive/50";
30
+ /** Override for meda `Checkbox` in booking forms. */
31
+ export declare const BOOKING_CHECKBOX_CLASS = "shadow-xs transition-shadow focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:bg-input/30";
32
+ /** A native `<select>` sized like a booking input. */
33
+ export declare const BOOKING_SELECT_CLASS = "h-12 w-full rounded-[4px] border border-input bg-transparent px-3 text-base outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50";
@@ -0,0 +1,35 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { cn } from '../../lib/utils.js';
4
+ const BUTTON_BASE = 'group/button inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap rounded-[3px] border border-transparent bg-clip-padding text-sm font-semibold transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*="size-"])]:size-4';
5
+ const BUTTON_VARIANTS = {
6
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
7
+ outline: 'border-[1.5px] border-primary/35 bg-transparent text-primary hover:border-primary hover:bg-primary/5 aria-expanded:bg-primary/5 aria-expanded:text-primary',
8
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
9
+ ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
10
+ destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30',
11
+ link: 'text-primary underline-offset-4 hover:underline',
12
+ };
13
+ const BUTTON_SIZES = {
14
+ default: 'h-10 gap-1.5 px-5 text-[15px]',
15
+ sm: 'h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5',
16
+ lg: 'h-auto gap-1.5 px-7 py-[15px] text-[15px]',
17
+ icon: 'size-9',
18
+ 'icon-sm': 'size-8 rounded-[min(var(--radius-md),10px)]',
19
+ };
20
+ /** Classes for a booking button — also for a link that should look like one. */
21
+ export function bookingButtonClass({ variant = 'default', size = 'default', className, } = {}) {
22
+ return cn(BUTTON_BASE, BUTTON_VARIANTS[variant], BUTTON_SIZES[size], className);
23
+ }
24
+ /** The booking screens' button. A native `<button type="button">` unless told otherwise. */
25
+ export function BookingButton({ variant, size, className, type = 'button', ...props }) {
26
+ return (_jsx("button", { type: type, "data-slot": "button", className: bookingButtonClass({ variant, size, className }), ...props }));
27
+ }
28
+ /** Override for meda `Input` in booking forms. */
29
+ export const BOOKING_INPUT_CLASS = 'h-12 rounded-[4px] border-[1.5px] border-input bg-background px-4 py-3 text-base transition-[color,border-color] focus-visible:border-primary focus-visible:ring-0 aria-invalid:border-destructive sm:text-base md:text-base dark:aria-invalid:border-destructive/50';
30
+ /** Override for meda `Textarea` in booking forms. */
31
+ export const BOOKING_TEXTAREA_CLASS = 'min-h-16 rounded-[4px] border-[1.5px] border-input bg-background px-4 py-3 text-base transition-[color,border-color] focus-visible:border-primary focus-visible:ring-0 aria-invalid:border-destructive sm:text-base md:text-base dark:aria-invalid:border-destructive/50';
32
+ /** Override for meda `Checkbox` in booking forms. */
33
+ export const BOOKING_CHECKBOX_CLASS = 'shadow-xs transition-shadow focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:bg-input/30';
34
+ /** A native `<select>` sized like a booking input. */
35
+ export const BOOKING_SELECT_CLASS = 'h-12 w-full rounded-[4px] border border-input bg-transparent px-3 text-base outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50';
@@ -0,0 +1,34 @@
1
+ import { ADD_CHILD_SHEET_LABEL_KEYS } from './add-child-sheet.js';
2
+ import { BOOKING_SKELETON_LABEL_KEYS } from './booking-skeleton.js';
3
+ import { CONFIRMATION_LABEL_KEYS } from './confirmation.js';
4
+ import { DETAILS_SCREEN_LABEL_KEYS } from './details-screen.js';
5
+ import { LOGIN_SHEET_LABEL_KEYS } from './login-sheet.js';
6
+ import { MANAGE_SCREEN_LABEL_KEYS } from './manage-screen.js';
7
+ import { CHILD_CARDS_LABEL_KEYS } from './portal/child-cards.js';
8
+ import { DATA_CONTROLS_LABEL_KEYS } from './portal/data-controls.js';
9
+ import { FAMILY_EDITOR_LABEL_KEYS } from './portal/family-editor.js';
10
+ import { LOGOUT_BUTTON_LABEL_KEYS } from './portal/logout-button.js';
11
+ import { PORTAL_SHELL_LABEL_KEYS } from './portal/portal-shell.js';
12
+ import { PORTAL_UNREACHABLE_LABEL_KEYS } from './portal/portal-unreachable.js';
13
+ import { PROFILE_FORM_LABEL_KEYS } from './portal/profile-form.js';
14
+ import { REBOOK_CARDS_LABEL_KEYS } from './portal/rebook-cards.js';
15
+ import { UPCOMING_BOOKINGS_LABEL_KEYS } from './portal/upcoming-bookings.js';
16
+ import { VIPPS_LINK_ROW_LABEL_KEYS } from './portal/vipps-link-row.js';
17
+ import { VISIT_HISTORY_LABEL_KEYS } from './portal/visit-history.js';
18
+ import { SERVICE_SCREEN_LABEL_KEYS } from './service-screen.js';
19
+ import { STYLIST_SCREEN_LABEL_KEYS } from './stylist-screen.js';
20
+ import { SUMMARY_BAR_LABEL_KEYS } from './summary-bar.js';
21
+ import { TIME_SCREEN_LABEL_KEYS } from './time-screen.js';
22
+ import { WHO_SCREEN_LABEL_KEYS } from './who-screen.js';
23
+ /**
24
+ * Every label key any booking screen reads, once. A complete pack for a
25
+ * locale is a `BookingLabels`; each screen's `labels` prop is a subset of it,
26
+ * so the same merged pack can be handed to every screen.
27
+ *
28
+ * (Screens that embed another screen already include its keys, e.g. the
29
+ * login sheet carries the login panel's, OTP slots' and Vipps button's.)
30
+ */
31
+ export declare const BOOKING_LABEL_KEYS: readonly ("addChild.trigger" | "addChild.title" | "addChild.description.saved" | "addChild.description.local" | "addChild.name" | "addChild.birthYear" | "addChild.birthYearPlaceholder" | "addChild.birthMonth" | "addChild.birthMonthUnknown" | "addChild.notes" | "addChild.missing" | "addChild.submit" | "addChild.close" | "bookingSkeleton.opening" | "confirmation.heading" | "confirmation.serviceFor" | "confirmation.when" | "confirmation.party.heading" | "confirmation.party.line" | "confirmation.party.stylist" | "confirmation.party.total" | "confirmation.calendar" | "confirmation.manage" | "confirmation.manageFor" | "confirmation.portal" | "confirmation.startOver" | "details.heading" | "details.phone.label" | "details.phone.prefix" | "details.phone.error" | "details.name.label" | "details.known.label" | "details.known.for" | "details.known.self" | "details.known.adult" | "details.known.child" | "details.child.name" | "details.child.year" | "details.child.yearPlaceholder" | "details.email.label" | "details.email.help" | "details.email.error" | "details.notes.label" | "details.notes.placeholder" | "details.terms.text" | "details.terms.link" | "details.marketing.text" | "details.error.maxParty" | "details.error.slotTaken" | "details.error.conflict" | "details.error.invalidInput" | "details.error.unconfigured" | "details.error.upstreamError" | "details.error.inProgress" | "details.call.link" | "details.call.none" | "details.submit" | "otp.label" | "vipps.button" | "vipps.unavailable" | "vipps.throttled" | "login.heading" | "login.codeHeading" | "login.or" | "login.emailIntro" | "login.emailLabel" | "login.sendCode" | "login.codeLabel" | "login.submitCode" | "login.codeHelp" | "login.codeHelpVipps" | "login.vippsSentTo" | "login.vippsSentToUnknown" | "login.cooldownWait" | "login.cooldownReady" | "login.resendIn" | "login.resend" | "login.switchEmail" | "login.useEmailInstead" | "login.notice.sent" | "login.notice.resent" | "login.notice.unreachable" | "login.notice.badEmail" | "login.notice.invalid" | "login.notice.throttled" | "login.notice.vippsInvalid" | "login.notice.vippsConflict" | "loginSheet.prompt" | "loginSheet.trigger" | "loginSheet.intro" | "loginSheet.vipps" | "loginSheet.close" | "time.heading" | "time.dayStrip.legend" | "time.nearest" | "time.current" | "time.nextFree" | "time.shortNotice" | "time.weekend.pct" | "time.weekend.mixed" | "time.month.show" | "time.month.hide" | "time.month.previous" | "time.month.next" | "time.month.heading" | "time.month.note" | "time.party.sequential" | "time.party.parallel" | "time.party.seat" | "time.party.seatSeparator" | "time.parallel.offer.two" | "time.parallel.offer.other" | "time.parallel.accept" | "time.empty.closed" | "time.empty.over" | "time.empty.full" | "time.empty.stylist" | "time.empty.unknown" | "time.empty.callLink" | "time.empty.callLinkAria" | "time.empty.callSuffix" | "time.empty.callPlain" | "time.loading" | "time.skeleton.dayPlaceholder" | "time.taken.message" | "time.taken.close" | "manage.heading" | "manage.serviceFallback" | "manage.card.named" | "manage.card.when" | "manage.card.whenWithStylist" | "manage.appointment.named" | "manage.appointment.namedSibilant" | "manage.appointment.unnamed" | "manage.ringUs" | "manage.ringUsOn" | "manage.callUs" | "manage.callUsOn" | "manage.error.windowPassed" | "manage.error.slotTaken" | "manage.error.notFound" | "manage.error.conflict" | "manage.error.invalidInput" | "manage.error.unavailable" | "manage.freeUntil" | "manage.remaining.in" | "manage.remaining.daysAndHours" | "manage.remaining.underMinute" | "manage.remaining.day.one" | "manage.remaining.day.other" | "manage.remaining.hour.one" | "manage.remaining.hour.other" | "manage.remaining.minute.one" | "manage.remaining.minute.other" | "manage.change" | "manage.cancel" | "manage.callToChange" | "manage.callToCancel" | "manage.blocked" | "manage.blocked.suffix" | "manage.blocked.plain" | "manage.calendar" | "manage.closed.cancelled" | "manage.closed.completed" | "manage.closed.noShow" | "manage.closed.pending" | "manage.closed.pendingCall" | "manage.closed.pendingCallPlain" | "manage.bookNew" | "manage.party.heading" | "manage.party.justThisOne" | "manage.party.unnamed" | "manage.party.note" | "manage.party.noteSuffix" | "manage.back" | "manage.reschedule.lead" | "manage.reschedule.leadWithStylist" | "manage.reschedule.failed" | "manage.reschedule.failedSuffix" | "manage.reschedule.loading" | "manage.move.question" | "manage.move.questionWithStylist" | "manage.move.surcharge" | "manage.move.confirm" | "manage.move.pickAnother" | "manage.cancel.question" | "manage.cancel.reasonLegend" | "manage.cancel.reason.illness" | "manage.cancel.reason.noLongerSuits" | "manage.cancel.reason.other" | "manage.cancel.confirm" | "manage.cancel.keep" | "manage.cancelled" | "manage.cancelled.findNew" | "manage.moved" | "manage.movedTo" | "manage.moved.view" | "manage.moved.noLink" | "manage.moved.noLinkSuffix" | "manage.portal" | "childCards.empty" | "childCards.neverVisited" | "childCards.lastVisit" | "childCards.lastVisitNoService" | "childCards.book" | "childCards.stylist" | "childCards.next" | "childCards.none" | "dataControls.heading" | "dataControls.export" | "dataControls.delete" | "dataControls.confirmHeading" | "dataControls.consequences" | "dataControls.confirmLabel" | "dataControls.confirmHelp" | "dataControls.wrongWord" | "dataControls.deleteForever" | "dataControls.cancel" | "dataControls.unreachable" | "dataControls.confirmWord" | "ageConfirm.heading" | "ageConfirm.lead" | "ageConfirm.legend" | "ageConfirm.birthYear" | "ageConfirm.birthMonth" | "ageConfirm.birthMonthUnknown" | "ageConfirm.confirm" | "ageConfirm.dismiss" | "familyEditor.heading" | "familyEditor.lead" | "familyEditor.empty" | "familyEditor.legend" | "familyEditor.add" | "familyEditor.name" | "familyEditor.birthYear" | "familyEditor.birthYearPlaceholder" | "familyEditor.birthMonth" | "familyEditor.birthMonthUnknown" | "familyEditor.stylist" | "familyEditor.stylistNone" | "familyEditor.stylistFormer" | "familyEditor.notes" | "familyEditor.notesPlaceholder" | "familyEditor.newRow" | "familyEditor.save" | "familyEditor.remove" | "familyEditor.removeNamed" | "familyEditor.removeRow" | "familyEditor.saved" | "familyEditor.savedNoDetails" | "familyEditor.partial" | "familyEditor.removed" | "familyEditor.unreachable" | "familyEditor.ageConfirmed" | "familyEditor.ageSaved" | "logout.button" | "logout.unreachable" | "portalShell.nav" | "portalShell.navBar" | "portalUnreachable.heading" | "portalUnreachable.body" | "portalUnreachable.retry" | "portalUnreachable.call" | "profileForm.heading" | "profileForm.firstName" | "profileForm.lastName" | "profileForm.phone" | "profileForm.email" | "profileForm.emailHelp" | "profileForm.save" | "profileForm.saved" | "profileForm.restored" | "profileForm.marketing" | "profileForm.unreachable" | "rebook.heading" | "rebook.empty" | "rebook.serviceFallback" | "rebook.anyStylist" | "rebook.cta" | "upcoming.heading" | "upcoming.empty" | "upcoming.book" | "upcoming.next" | "upcoming.today" | "upcoming.tomorrow" | "upcoming.inDays" | "upcoming.when" | "upcoming.serviceFallback" | "upcoming.anyStylist" | "upcoming.manage" | "upcoming.noManage" | "upcoming.noManageWithPhone" | "upcoming.call" | "vippsLink.heading" | "vippsLink.linked" | "vippsLink.pitch" | "vippsLink.button" | "vippsLink.success" | "vippsLink.conflict" | "vippsLink.failed" | "vippsLink.unavailable" | "vippsLink.throttled" | "history.heading" | "history.empty" | "history.serviceFallback" | "history.count.one" | "history.count.other" | "history.unknownAmount" | "service.heading" | "service.categoriesLegend" | "service.sameAsLast" | "service.chooseOther" | "service.duration" | "service.phoneOnly" | "service.phoneOnlyLinkLabel" | "service.ageDivider.named" | "service.ageDivider.unnamed" | "service.party.listLabel" | "service.party.adultAlone" | "service.party.nothingFits" | "service.party.remove" | "stylist.heading" | "stylist.firstAvailable" | "stylist.firstAvailableSubtitle" | "stylist.firstAvailableBadge" | "stylist.pendingName" | "stylist.nextAvailable" | "stylist.loading" | "stylist.party.legend" | "stylist.party.sequential" | "stylist.party.sequentialMinutes" | "stylist.party.parallel.two" | "stylist.party.parallel.three" | "stylist.party.parallel.other" | "stylist.party.parallelMinutes" | "stylist.party.parallelNote.two" | "stylist.party.parallelNote.three" | "stylist.party.parallelNote.other" | "summary.placeholder.who" | "summary.placeholder.service" | "summary.next" | "who.heading" | "who.guest.groupLabel" | "who.guest.children.one" | "who.guest.children.other" | "who.guest.adult" | "who.guest.addedList" | "who.family.legend" | "who.family.self" | "who.family.limit")[];
32
+ export type BookingLabelKey = (typeof WHO_SCREEN_LABEL_KEYS)[number] | (typeof ADD_CHILD_SHEET_LABEL_KEYS)[number] | (typeof SERVICE_SCREEN_LABEL_KEYS)[number] | (typeof STYLIST_SCREEN_LABEL_KEYS)[number] | (typeof TIME_SCREEN_LABEL_KEYS)[number] | (typeof MANAGE_SCREEN_LABEL_KEYS)[number] | (typeof DETAILS_SCREEN_LABEL_KEYS)[number] | (typeof SUMMARY_BAR_LABEL_KEYS)[number] | (typeof CONFIRMATION_LABEL_KEYS)[number] | (typeof BOOKING_SKELETON_LABEL_KEYS)[number] | (typeof LOGIN_SHEET_LABEL_KEYS)[number] | (typeof PORTAL_SHELL_LABEL_KEYS)[number] | (typeof PORTAL_UNREACHABLE_LABEL_KEYS)[number] | (typeof UPCOMING_BOOKINGS_LABEL_KEYS)[number] | (typeof VISIT_HISTORY_LABEL_KEYS)[number] | (typeof REBOOK_CARDS_LABEL_KEYS)[number] | (typeof CHILD_CARDS_LABEL_KEYS)[number] | (typeof FAMILY_EDITOR_LABEL_KEYS)[number] | (typeof PROFILE_FORM_LABEL_KEYS)[number] | (typeof DATA_CONTROLS_LABEL_KEYS)[number] | (typeof LOGOUT_BUTTON_LABEL_KEYS)[number] | (typeof VIPPS_LINK_ROW_LABEL_KEYS)[number];
33
+ /** A complete label pack for one locale. */
34
+ export type BookingLabels = Record<BookingLabelKey, string>;