@medalsocial/meda 3.0.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +36 -2
  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 +316 -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 +36 -0
  13. package/dist/booking/index.js +41 -0
  14. package/dist/booking/internal/label-parts.d.ts +28 -0
  15. package/dist/booking/internal/label-parts.js +57 -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 +372 -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 +104 -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 +84 -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 +140 -0
  61. package/dist/booking/service-screen.js +235 -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 +128 -0
  65. package/dist/booking/stylist-screen.js +229 -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 +419 -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/checkbox.d.ts +23 -0
  77. package/dist/primitives/checkbox.js +20 -0
  78. package/dist/primitives/field.d.ts +47 -0
  79. package/dist/primitives/field.js +48 -0
  80. package/dist/primitives/index.d.ts +8 -0
  81. package/dist/primitives/index.js +4 -0
  82. package/dist/primitives/sheet.d.ts +68 -0
  83. package/dist/primitives/sheet.js +303 -0
  84. package/dist/primitives/textarea.d.ts +14 -0
  85. package/dist/primitives/textarea.js +13 -0
  86. package/dist/styles/booking.css +10 -0
  87. package/package.json +15 -8
  88. package/skills/components/SKILL.md +53 -0
@@ -0,0 +1,316 @@
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 { renderLabel } from './internal/label-parts.js';
9
+ import { BOOKING_CHECKBOX_CLASS, BOOKING_INPUT_CLASS, BOOKING_TEXTAREA_CLASS, BookingButton, } from './internal/ui.js';
10
+ import { fillLabel } from './labels.js';
11
+ import { slotClass } from './slots.js';
12
+ /**
13
+ * The last step of the booking wizard: contact details, who each seat is for,
14
+ * the consents, and the button that books.
15
+ *
16
+ * Presentational, with the same bargain the other steps make: every answer
17
+ * leaves as an action (`onChange`) and the caller's machine decides what it
18
+ * means. What stays here is local UI state that is not an answer about the
19
+ * booking — the keystrokes behind the per-child fields, which field has focus,
20
+ * whether submit has been pressed, and a fallback submission nonce.
21
+ */
22
+ // ---------------------------------------------------------------------------
23
+ // Labels
24
+ // ---------------------------------------------------------------------------
25
+ export const DETAILS_SCREEN_LABEL_KEYS = [
26
+ 'details.heading',
27
+ 'details.phone.label',
28
+ /** The country code shown (not typed) before the number. Empty string hides it. */
29
+ 'details.phone.prefix',
30
+ 'details.phone.error',
31
+ 'details.name.label',
32
+ /** Accessible name of the list of seats step 1 already named. */
33
+ 'details.known.label',
34
+ /** `{who}` follows the bold service name: «for {who}». */
35
+ 'details.known.for',
36
+ 'details.known.self',
37
+ 'details.known.adult',
38
+ 'details.known.child',
39
+ 'details.child.name',
40
+ 'details.child.year',
41
+ 'details.child.yearPlaceholder',
42
+ 'details.email.label',
43
+ 'details.email.help',
44
+ 'details.email.error',
45
+ 'details.notes.label',
46
+ 'details.notes.placeholder',
47
+ 'details.terms.text',
48
+ /** Link text after the terms sentence; only drawn when `termsHref` is set. */
49
+ 'details.terms.link',
50
+ 'details.marketing.text',
51
+ 'details.error.maxParty',
52
+ 'details.error.slotTaken',
53
+ 'details.error.conflict',
54
+ 'details.error.invalidInput',
55
+ 'details.error.unconfigured',
56
+ 'details.error.upstreamError',
57
+ 'details.error.inProgress',
58
+ /** `{phone}` — visible text and accessible name of the call link. */
59
+ 'details.call.link',
60
+ /** The rescue sentence when there is no phone number to link. */
61
+ 'details.call.none',
62
+ /** `{price}` — the total, already formatted. */
63
+ 'details.submit',
64
+ ];
65
+ /**
66
+ * The label key for each failure the wizard can carry. Exported because some
67
+ * of them (e.g. `maxParty`) are raised on earlier steps, where the shell shows
68
+ * the same sentence — one map, so one refusal is never explained two ways.
69
+ */
70
+ export const DETAILS_ERROR_LABEL_KEYS = {
71
+ maxParty: 'details.error.maxParty',
72
+ slotTaken: 'details.error.slotTaken',
73
+ conflict: 'details.error.conflict',
74
+ invalidInput: 'details.error.invalidInput',
75
+ unconfigured: 'details.error.unconfigured',
76
+ upstreamError: 'details.error.upstreamError',
77
+ inProgress: 'details.error.inProgress',
78
+ };
79
+ /** One saved child offered as a single tap above the child fields. */
80
+ export function DefaultFamilyChip({ member, pressed, onPick }) {
81
+ return (_jsx(BookingButton, { variant: "outline", size: "sm", "aria-pressed": pressed, className: "rounded-full", onClick: onPick, children: member.name }));
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // Helpers
85
+ // ---------------------------------------------------------------------------
86
+ /**
87
+ * Deliberately loose: one `@`, something on each side, a dot in the domain,
88
+ * no spaces. The field is optional, so the only job is to catch an address
89
+ * that cannot receive anything. Not RFC 5322 — a stricter pattern rejects real
90
+ * addresses.
91
+ */
92
+ /**
93
+ * A random submission id. `crypto.randomUUID` only exists in secure contexts
94
+ * (https, localhost); a plain-http origin falls back to `getRandomValues`,
95
+ * which is available everywhere, shaped as a v4 UUID.
96
+ */
97
+ function mintNonce() {
98
+ if (typeof crypto.randomUUID === 'function')
99
+ return crypto.randomUUID();
100
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
101
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
102
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
103
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
104
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
105
+ }
106
+ /** Blank is fine (the field is optional). Anything else has to be reachable. */
107
+ export function looksLikeEmail(value) {
108
+ const trimmed = value.trim();
109
+ if (trimmed.length === 0)
110
+ return true;
111
+ // Hand-rolled rather than a regex: linear on any input, same rule as
112
+ // /^[^\s@]+@[^\s@]+\.[^\s@]+$/.
113
+ if (/\s/.test(trimmed))
114
+ return false;
115
+ const at = trimmed.indexOf('@');
116
+ if (at < 1 || at !== trimmed.lastIndexOf('@'))
117
+ return false;
118
+ const domain = trimmed.slice(at + 1);
119
+ for (let index = 1; index < domain.length - 1; index += 1) {
120
+ if (domain[index] === '.')
121
+ return true;
122
+ }
123
+ return false;
124
+ }
125
+ const NO_CHILD = { name: '', birthYear: '' };
126
+ function seedChildren(items) {
127
+ return Object.fromEntries(items.map((item, index) => [
128
+ index,
129
+ {
130
+ name: item.bookedForName ?? '',
131
+ birthYear: item.bookedForBirthYear === undefined ? '' : String(item.bookedForBirthYear),
132
+ },
133
+ ]));
134
+ }
135
+ /** Four digits and nothing else — `parseInt` would accept a numeric prefix. */
136
+ const FOUR_DIGITS = /^\d{4}$/;
137
+ const EARLIEST_BIRTH_YEAR = 1900;
138
+ const LATEST_BIRTH_YEAR = 2200;
139
+ /** The year as a number, or `null` for «nothing usable typed». */
140
+ function parseBirthYear(raw) {
141
+ const trimmed = raw.trim();
142
+ if (!FOUR_DIGITS.test(trimmed))
143
+ return null;
144
+ const year = Number(trimmed);
145
+ return year >= EARLIEST_BIRTH_YEAR && year <= LATEST_BIRTH_YEAR ? year : null;
146
+ }
147
+ /** Spread into an object literal, so «nothing typed» is an absent key, not `''`. */
148
+ function present(key, value) {
149
+ const trimmed = value.trim();
150
+ return trimmed.length > 0 ? { [key]: trimmed } : undefined;
151
+ }
152
+ /** Who a seat step 1 already named is for, in words. */
153
+ function knownWho(state, index, labels) {
154
+ const item = state.items[index];
155
+ if (item?.adult) {
156
+ return state.people[index]?.key === 'self'
157
+ ? labels['details.known.self']
158
+ : labels['details.known.adult'];
159
+ }
160
+ return item?.bookedForName ?? labels['details.known.child'];
161
+ }
162
+ /**
163
+ * Whether step 1 already said who sits in this seat: a saved person's card
164
+ * (`p:` / `n:`), the logged-in guardian (`self`) or a guest adult (`adult`).
165
+ */
166
+ function isKnownChair(state, index) {
167
+ const key = state.people[index]?.key ?? '';
168
+ return key.startsWith('p:') || key.startsWith('n:') || key === 'self' || key === 'adult';
169
+ }
170
+ // ---------------------------------------------------------------------------
171
+ // Screen
172
+ // ---------------------------------------------------------------------------
173
+ 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, }) {
174
+ const ids = useId();
175
+ const field = (name) => `${ids}-${name}`;
176
+ const phoneRef = useRef(null);
177
+ const emailRef = useRef(null);
178
+ const termsRef = useRef(null);
179
+ // The text on the way to the machine's answers. Seeded from the items so a
180
+ // visitor who steps back and returns finds the names still in the boxes;
181
+ // keyed by index because two lines can share a service.
182
+ const [children, setChildren] = useState(() => seedChildren(state.items));
183
+ const childAt = (index) => children[index] ?? NO_CHILD;
184
+ // Minted once per mount (a lazy initialiser), never per press: a
185
+ // double-tapped submit must carry the same nonce twice.
186
+ // Only minted when the caller does not pass one (lazily, once per mount).
187
+ const ownNonce = useRef(null);
188
+ if (nonceProp === undefined && ownNonce.current === null)
189
+ ownNonce.current = mintNonce();
190
+ const submissionNonce = nonceProp ?? ownNonce.current ?? '';
191
+ // Which field has focus: the phone error is held back while the visitor is
192
+ // still typing and appears the moment the field is left.
193
+ const [focused, setFocused] = useState(null);
194
+ // Submit is never disabled for validation, so pressing it is what explains a refusal.
195
+ const [attempted, setAttempted] = useState(false);
196
+ const phoneTyped = state.contact.phone.trim().length > 0;
197
+ const phoneValid = phoneLooksValid(state.contact.phone);
198
+ const emailValid = looksLikeEmail(state.contact.email);
199
+ const showPhoneError = !phoneValid && (attempted || (phoneTyped && focused !== 'phone'));
200
+ const showEmailError = !emailValid && (attempted || focused !== 'email');
201
+ const showTermsError = attempted && !state.consentTerms;
202
+ const price = format.price(totalOre);
203
+ const phonePrefix = labels['details.phone.prefix'];
204
+ const FamilyChip = components?.FamilyChip ?? DefaultFamilyChip;
205
+ function setContact(name, value) {
206
+ onChange({ type: 'setContact', field: name, value });
207
+ }
208
+ /** Written to the buffer (what the field shows) and to the machine (the answer). */
209
+ function setChild(index, patch, personId) {
210
+ setChildren((previous) => ({
211
+ ...previous,
212
+ [index]: { ...(previous[index] ?? NO_CHILD), ...patch },
213
+ }));
214
+ if (patch.name !== undefined) {
215
+ onChange({ type: 'setItemField', index, field: 'bookedForName', value: patch.name });
216
+ // A family chip names a saved person; a name typed by hand may be someone
217
+ // the business does not know.
218
+ onChange({
219
+ type: 'setItemField',
220
+ index,
221
+ field: 'bookedForPersonId',
222
+ value: personId ?? null,
223
+ });
224
+ }
225
+ if (patch.birthYear !== undefined) {
226
+ onChange({
227
+ type: 'setItemField',
228
+ index,
229
+ field: 'bookedForBirthYear',
230
+ value: parseBirthYear(patch.birthYear),
231
+ });
232
+ }
233
+ }
234
+ function submissionItems(startTs) {
235
+ // A saved person's id is only valid under the number of the guardian who owns them.
236
+ const ownPhone = guardianPhone !== null &&
237
+ normalisePhone(guardianPhone) === normalisePhone(state.contact.phone);
238
+ return state.items.map((item, index) => {
239
+ const child = childAt(index);
240
+ const birthYear = parseBirthYear(child.birthYear);
241
+ const line = lines[index];
242
+ const resourceId = line?.resourceId ?? null;
243
+ return {
244
+ serviceId: item.service.id,
245
+ ...(resourceId === null ? {} : { resourceId }),
246
+ startTs: line?.startTs ?? startTs,
247
+ ...present('bookedForName', isKnownChair(state, index) ? (item.bookedForName ?? '') : child.name),
248
+ ...(birthYear === null ? {} : { bookedForBirthYear: birthYear }),
249
+ ...(ownPhone && item.bookedForPersonId
250
+ ? { bookedForPersonId: item.bookedForPersonId }
251
+ : {}),
252
+ };
253
+ });
254
+ }
255
+ function handleSubmit() {
256
+ setAttempted(true);
257
+ // Unreachable through the wizard, but submitting without a slot would book 1970.
258
+ if (state.startTs === null)
259
+ return;
260
+ // Focus rather than scroll: it moves the viewport, puts the caret where the
261
+ // fix is needed, and works for whoever is not looking at the screen.
262
+ if (!phoneValid) {
263
+ phoneRef.current?.focus();
264
+ return;
265
+ }
266
+ if (!emailValid) {
267
+ emailRef.current?.focus();
268
+ return;
269
+ }
270
+ if (!state.consentTerms) {
271
+ termsRef.current?.focus();
272
+ return;
273
+ }
274
+ onSubmit({
275
+ items: submissionItems(state.startTs),
276
+ contact: {
277
+ phone: normalisePhone(state.contact.phone),
278
+ ...present('name', state.contact.name),
279
+ ...present('email', state.contact.email),
280
+ },
281
+ ...present('notes', state.notes),
282
+ consentTerms: state.consentTerms,
283
+ consentMarketing: state.consentMarketing,
284
+ submissionNonce,
285
+ });
286
+ }
287
+ const inputClass = (...extra) => slotClass(classNames, 'input', BOOKING_INPUT_CLASS, ...extra);
288
+ const fieldClass = slotClass(classNames, 'field');
289
+ 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 }), ' ', renderLabel(labels['details.known.for'], { who: knownWho(state, index, labels) })] }, index)) : null) })), state.items.map((item, index) =>
290
+ // A seat step 1 already named is not asked again: a second answer could disagree.
291
+ isKnownChair(state, index) ? null : (_jsx(ChildFields
292
+ // biome-ignore lint/suspicious/noArrayIndexKey: two lines can share a service; the index is the identity
293
+ , { 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: renderLabel(labels['details.submit'], { price }) })] }));
294
+ }
295
+ /**
296
+ * The name and birth year for one line. Wrapped in a fieldset only when there
297
+ * is more than one line: two identically labelled fields with nothing between
298
+ * them is how one child's name lands on the other's booking.
299
+ */
300
+ function ChildFields({ legend, nameId, yearId, value, family, labels, classNames, FamilyChip, onChange, }) {
301
+ 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 }) })] })] }));
302
+ 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] }));
303
+ if (legend === null)
304
+ return withSuggestions;
305
+ 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] }));
306
+ }
307
+ /**
308
+ * A failed submit must not be silent: announced (nothing else on the screen
309
+ * moved), and it offers the phone, because what reaches here is what the
310
+ * visitor cannot fix by trying harder.
311
+ */
312
+ function SubmitFailure({ error, phone, format, labels, className, }) {
313
+ const template = labels['details.call.link'];
314
+ const call = phone ? fillLabel(template, { phone }) : null;
315
+ 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: renderLabel(template, { phone }) })) : (labels['details.call.none'])] }));
316
+ }
@@ -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,36 @@
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 { joinLabelParts, labelParts, renderLabel } from './internal/label-parts.js';
7
+ export * from './internal/ui.js';
8
+ export * from './label-keys.js';
9
+ export { fillLabel } from './labels.js';
10
+ export * from './live-status.js';
11
+ export * from './login-panel.js';
12
+ export * from './login-sheet.js';
13
+ export * from './manage-screen.js';
14
+ export * from './otp-slots.js';
15
+ export * from './portal/account-card.js';
16
+ export * from './portal/action-result.js';
17
+ export * from './portal/age-confirm-card.js';
18
+ export * from './portal/child-cards.js';
19
+ export * from './portal/data-controls.js';
20
+ export * from './portal/family-editor.js';
21
+ export * from './portal/logout-button.js';
22
+ export * from './portal/portal-shell.js';
23
+ export * from './portal/portal-unreachable.js';
24
+ export * from './portal/profile-form.js';
25
+ export * from './portal/rebook-cards.js';
26
+ export * from './portal/upcoming-bookings.js';
27
+ export * from './portal/vipps-link-row.js';
28
+ export * from './portal/visit-history.js';
29
+ export * from './service-screen.js';
30
+ export type { ScreenComponents, SlotClassNames } from './slots.js';
31
+ export * from './stylist-screen.js';
32
+ export * from './summary-bar.js';
33
+ export * from './time-screen.js';
34
+ export type * from './types.js';
35
+ export * from './vipps-button.js';
36
+ export * from './who-screen.js';
@@ -0,0 +1,41 @@
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 { joinLabelParts, labelParts, renderLabel } from './internal/label-parts.js';
14
+ export * from './internal/ui.js';
15
+ export * from './label-keys.js';
16
+ export { fillLabel } from './labels.js';
17
+ export * from './live-status.js';
18
+ export * from './login-panel.js';
19
+ export * from './login-sheet.js';
20
+ export * from './manage-screen.js';
21
+ export * from './otp-slots.js';
22
+ export * from './portal/account-card.js';
23
+ export * from './portal/action-result.js';
24
+ export * from './portal/age-confirm-card.js';
25
+ export * from './portal/child-cards.js';
26
+ export * from './portal/data-controls.js';
27
+ export * from './portal/family-editor.js';
28
+ export * from './portal/logout-button.js';
29
+ export * from './portal/portal-shell.js';
30
+ export * from './portal/portal-unreachable.js';
31
+ export * from './portal/profile-form.js';
32
+ export * from './portal/rebook-cards.js';
33
+ export * from './portal/upcoming-bookings.js';
34
+ export * from './portal/vipps-link-row.js';
35
+ export * from './portal/visit-history.js';
36
+ export * from './service-screen.js';
37
+ export * from './stylist-screen.js';
38
+ export * from './summary-bar.js';
39
+ export * from './time-screen.js';
40
+ export * from './vipps-button.js';
41
+ export * from './who-screen.js';
@@ -0,0 +1,28 @@
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;
9
+ /**
10
+ * A filled label as element children: the template split around its
11
+ * `{placeholders}`, so React renders one text node per literal run and one
12
+ * per value — the same DOM as writing the sentence out in JSX
13
+ * (`Total {total} · paid at the salon`). Prefer it over `fillLabel` wherever
14
+ * a label is rendered as children; a browser lays text out per text node, so
15
+ * one joined string can land a line's glyphs a sub-pixel away from the same
16
+ * sentence composed in JSX.
17
+ *
18
+ * Text-only targets (`aria-label`, `title`, a file name) keep `fillLabel`.
19
+ * Empty literal runs and empty values render nothing, as in JSX; a label
20
+ * that fills to nothing at all is `null`.
21
+ */
22
+ export declare function renderLabel(template: string, values: Readonly<Record<string, string | number>>): ReactNode;
23
+ /**
24
+ * `parts.filter(Boolean).join(separator)` as element children: every part
25
+ * and every separator its own text piece. Blank parts drop out, so an absent
26
+ * one closes the gap rather than leaving a doubled separator.
27
+ */
28
+ export declare function joinLabelParts(parts: ReadonlyArray<ReactNode>, separator: string): ReactNode;
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } 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
+ }
20
+ /**
21
+ * A filled label as element children: the template split around its
22
+ * `{placeholders}`, so React renders one text node per literal run and one
23
+ * per value — the same DOM as writing the sentence out in JSX
24
+ * (`Total {total} · paid at the salon`). Prefer it over `fillLabel` wherever
25
+ * a label is rendered as children; a browser lays text out per text node, so
26
+ * one joined string can land a line's glyphs a sub-pixel away from the same
27
+ * sentence composed in JSX.
28
+ *
29
+ * Text-only targets (`aria-label`, `title`, a file name) keep `fillLabel`.
30
+ * Empty literal runs and empty values render nothing, as in JSX; a label
31
+ * that fills to nothing at all is `null`.
32
+ */
33
+ export function renderLabel(template, values) {
34
+ const pieces = template.split(/(\{\w+\})/g);
35
+ const nodes = [];
36
+ for (const piece of pieces) {
37
+ const match = /^\{(\w+)\}$/.exec(piece);
38
+ const value = match && Object.hasOwn(values, match[1]) ? values[match[1]] : piece;
39
+ if (value !== '')
40
+ nodes.push(value);
41
+ }
42
+ return nodes.length > 0 ? nodes : null;
43
+ }
44
+ /**
45
+ * `parts.filter(Boolean).join(separator)` as element children: every part
46
+ * and every separator its own text piece. Blank parts drop out, so an absent
47
+ * one closes the gap rather than leaving a doubled separator.
48
+ */
49
+ export function joinLabelParts(parts, separator) {
50
+ return parts
51
+ .filter((part) => part !== null &&
52
+ part !== undefined &&
53
+ part !== false &&
54
+ part !== '' &&
55
+ !(Array.isArray(part) && part.length === 0))
56
+ .map((part, index) => (_jsxs(Fragment, { children: [index > 0 && separator, part] }, index)));
57
+ }
@@ -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';