@medalsocial/meda 3.3.0 → 3.5.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 (62) hide show
  1. package/README.md +35 -1
  2. package/dist/booking/add-child-sheet.d.ts +2 -1
  3. package/dist/booking/add-child-sheet.js +2 -1
  4. package/dist/booking/booking-skeleton.d.ts +2 -1
  5. package/dist/booking/confirmation.d.ts +2 -1
  6. package/dist/booking/confirmation.js +7 -6
  7. package/dist/booking/details-screen.d.ts +5 -1
  8. package/dist/booking/details-screen.js +13 -8
  9. package/dist/booking/index.d.ts +2 -2
  10. package/dist/booking/index.js +2 -2
  11. package/dist/booking/internal/label-parts.d.ts +36 -21
  12. package/dist/booking/internal/label-parts.js +71 -43
  13. package/dist/booking/internal/service-multi.d.ts +55 -0
  14. package/dist/booking/internal/service-multi.js +83 -0
  15. package/dist/booking/internal/visit.d.ts +13 -0
  16. package/dist/booking/internal/visit.js +10 -0
  17. package/dist/booking/label-keys.d.ts +13 -5
  18. package/dist/booking/label-keys.js +2 -1
  19. package/dist/booking/labels.d.ts +37 -8
  20. package/dist/booking/labels.js +45 -8
  21. package/dist/booking/login-panel.d.ts +3 -2
  22. package/dist/booking/login-panel.js +3 -2
  23. package/dist/booking/login-sheet.d.ts +2 -1
  24. package/dist/booking/login-sheet.js +2 -1
  25. package/dist/booking/manage-screen.d.ts +2 -1
  26. package/dist/booking/manage-screen.js +16 -11
  27. package/dist/booking/otp-slots.d.ts +2 -1
  28. package/dist/booking/otp-slots.js +2 -1
  29. package/dist/booking/portal/age-confirm-card.d.ts +2 -1
  30. package/dist/booking/portal/child-cards.d.ts +2 -1
  31. package/dist/booking/portal/data-controls.d.ts +2 -1
  32. package/dist/booking/portal/data-controls.js +3 -3
  33. package/dist/booking/portal/family-editor.d.ts +2 -1
  34. package/dist/booking/portal/family-editor.js +9 -9
  35. package/dist/booking/portal/logout-button.d.ts +2 -1
  36. package/dist/booking/portal/logout-button.js +2 -1
  37. package/dist/booking/portal/portal-shell.d.ts +2 -1
  38. package/dist/booking/portal/portal-shell.js +3 -2
  39. package/dist/booking/portal/portal-unreachable.d.ts +2 -1
  40. package/dist/booking/portal/profile-form.d.ts +2 -1
  41. package/dist/booking/portal/profile-form.js +3 -2
  42. package/dist/booking/portal/rebook-cards.d.ts +2 -1
  43. package/dist/booking/portal/rebook-cards.js +3 -2
  44. package/dist/booking/portal/upcoming-bookings.d.ts +2 -1
  45. package/dist/booking/portal/upcoming-bookings.js +3 -3
  46. package/dist/booking/portal/vipps-link-row.d.ts +2 -1
  47. package/dist/booking/portal/vipps-link-row.js +7 -6
  48. package/dist/booking/portal/visit-history.d.ts +2 -1
  49. package/dist/booking/service-screen.d.ts +78 -3
  50. package/dist/booking/service-screen.js +131 -46
  51. package/dist/booking/stylist-screen.d.ts +2 -1
  52. package/dist/booking/stylist-screen.js +7 -6
  53. package/dist/booking/summary-bar.d.ts +2 -1
  54. package/dist/booking/time-screen.d.ts +2 -1
  55. package/dist/booking/time-screen.js +42 -19
  56. package/dist/booking/types.d.ts +14 -1
  57. package/dist/booking/vipps-button.d.ts +2 -1
  58. package/dist/booking/vipps-button.js +2 -1
  59. package/dist/booking/who-screen.d.ts +2 -1
  60. package/dist/booking/who-screen.js +4 -3
  61. package/package.json +1 -1
  62. package/skills/components/SKILL.md +4 -0
package/README.md CHANGED
@@ -81,13 +81,47 @@ ships no default language and no brand. Override, cheapest first:
81
81
  1. **CSS variables** — the shadcn tokens the bridge maps (`--primary`, `--card`,
82
82
  `--muted`, `--border`, `--ring`, `--radius`, …). Colours come from nowhere else.
83
83
  2. **`labels`** — a flat record (`'who.heading'`, …); `BookingLabels` is the full set
84
- and `BOOKING_LABEL_KEYS` lists it. `{placeholders}` are filled by the screen.
84
+ and `BOOKING_LABEL_KEYS` lists it. `{placeholders}` are filled by the screen. A
85
+ value is a `BookingLabel`: a **string** renders as one text node once filled (like
86
+ a template literal), a **string array** renders one text node per element, each
87
+ element filled with the same values (see below).
85
88
  3. **`classNames`** — per documented slot (`root`, `card`, `cardSelected`, `chip`, …),
86
89
  merged after the defaults so an override wins.
87
90
  4. **`components`** — card renderers (`ServiceCard`, `StylistCard`, `TimeChip`,
88
91
  `DayChip`, `PersonCard`, …). The defaults are exported (`DefaultServiceCard`, …)
89
92
  so an override can wrap them.
90
93
 
94
+ ### Where a sentence breaks into text nodes
95
+
96
+ A browser lays text out per DOM text node, so the same sentence split differently can
97
+ land its later glyphs a sub-pixel apart. The pack decides; a screen never splits a
98
+ string on its own:
99
+
100
+ ```ts
101
+ // One text node: «Total 840 kr · paid at the salon».
102
+ const asOneNode = {
103
+ 'confirmation.party.total': 'Total {total} · paid at the salon',
104
+ } satisfies Partial<BookingLabels>;
105
+
106
+ // Four text nodes, the DOM of `Total {total}{' · '}paid at the salon` in JSX.
107
+ const asPieces = {
108
+ 'confirmation.party.total': ['Total ', '{total}', ' · ', 'paid at the salon'],
109
+ } satisfies Partial<BookingLabels>;
110
+ ```
111
+
112
+ - An element that fills to `''` renders nothing; a label that fills to nothing renders
113
+ nothing at all.
114
+ - Lines joined from several parts (the confirmation card's `service · stylist · when ·
115
+ price`, the portal's booking lines) are one text node while every part is a string,
116
+ and in pieces (each part and separator its own node) once one of their labels is an
117
+ array. `joinLabelParts(parts, separator, { pieces })` forces either.
118
+ - Text-only targets — `aria-label`, `placeholder`, file names, error and status
119
+ messages, live-region announcements, a value filled into another label, and a custom
120
+ component's `string` prop — read the label flattened (`fillLabel`, `labelText`).
121
+ - Helpers, for UI composed around the screens: `renderLabel(label, values)` (element
122
+ children), `fillLabel(label, values)` (text), `labelParts(label, { hole: <el/> })`
123
+ (element holes), `fillLabelPieces`, `labelText`, `joinLabelParts`.
124
+
91
125
  ```css
92
126
  @import 'tailwindcss';
93
127
  @import '@medalsocial/meda/styles/bridge.css'; /* your own shadcn tokens */
@@ -1,4 +1,5 @@
1
1
  import type { BookingFormat } from './format.js';
2
+ import { type BookingLabel } from './labels.js';
2
3
  import { type SlotClassNames } from './slots.js';
3
4
  import type { NewChild, SaveResult } from './types.js';
4
5
  /**
@@ -13,7 +14,7 @@ import type { NewChild, SaveResult } from './types.js';
13
14
  * save — so a child is never shown as added before it exists.
14
15
  */
15
16
  export declare const ADD_CHILD_SHEET_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"];
16
- export type AddChildSheetLabels = Record<(typeof ADD_CHILD_SHEET_LABEL_KEYS)[number], string>;
17
+ export type AddChildSheetLabels = Record<(typeof ADD_CHILD_SHEET_LABEL_KEYS)[number], BookingLabel>;
17
18
  /**
18
19
  * - `root` — the dashed trigger card (the only element in the page flow)
19
20
  * - `content` — the sheet (`<dialog>`)
@@ -7,6 +7,7 @@ import { Input } from '../primitives/input.js';
7
7
  import { Sheet } from '../primitives/sheet.js';
8
8
  import { Textarea } from '../primitives/textarea.js';
9
9
  import { BOOKING_INPUT_CLASS, BOOKING_SELECT_CLASS, BOOKING_TEXTAREA_CLASS, BookingButton, } from './internal/ui.js';
10
+ import { labelText } from './labels.js';
10
11
  import { slotClass } from './slots.js';
11
12
  /**
12
13
  * «+ Add child» — a dashed card that opens a small sheet: name, birth year,
@@ -58,7 +59,7 @@ export function AddChildSheet({ labels, format, saveNotes, disabled, onSave, cur
58
59
  event.preventDefault();
59
60
  const trimmed = name.trim();
60
61
  if (trimmed === '' || year === '') {
61
- setError(labels['addChild.missing']);
62
+ setError(labelText(labels['addChild.missing']));
62
63
  return;
63
64
  }
64
65
  startTransition(async () => {
@@ -1,7 +1,8 @@
1
+ import type { BookingLabel } from './labels.js';
1
2
  import { type SlotClassNames } from './slots.js';
2
3
  export declare const BOOKING_SKELETON_LABEL_KEYS: readonly ["bookingSkeleton.opening"];
3
4
  /** Copy for the pending skeleton: the sentence a screen reader hears while it is up. */
4
- export type BookingSkeletonLabels = Record<(typeof BOOKING_SKELETON_LABEL_KEYS)[number], string>;
5
+ export type BookingSkeletonLabels = Record<(typeof BOOKING_SKELETON_LABEL_KEYS)[number], BookingLabel>;
5
6
  /**
6
7
  * `classNames` slots: `root` the fixed overlay · `frame` the pulsing column ·
7
8
  * `bar` the active step-bar segment · `chip` one of the four person chips.
@@ -1,5 +1,6 @@
1
1
  import type { ComponentType } from 'react';
2
2
  import type { BookingFormat } from './format.js';
3
+ import { type BookingLabel } from './labels.js';
3
4
  import { type SlotClassNames } from './slots.js';
4
5
  import type { WizardItem } from './types.js';
5
6
  /**
@@ -14,7 +15,7 @@ import type { WizardItem } from './types.js';
14
15
  * link to a page that 404s reads as a booking that did not quite happen.
15
16
  */
16
17
  export declare const CONFIRMATION_LABEL_KEYS: readonly ["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"];
17
- export type ConfirmationLabels = Record<(typeof CONFIRMATION_LABEL_KEYS)[number], string>;
18
+ export type ConfirmationLabels = Record<(typeof CONFIRMATION_LABEL_KEYS)[number], BookingLabel>;
18
19
  /**
19
20
  * One booked line (one person's appointment). One array rather than parallel
20
21
  * lists, because every part answers about the SAME line.
@@ -2,7 +2,8 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { joinLabelParts, renderLabel } from './internal/label-parts.js';
4
4
  import { BookingButton } from './internal/ui.js';
5
- import { fillLabel } from './labels.js';
5
+ import { visitName } from './internal/visit.js';
6
+ import { fillLabel, labelText } from './labels.js';
6
7
  import { slotClass } from './slots.js';
7
8
  /**
8
9
  * The confirmation screen — the one the whole flow exists to reach.
@@ -54,10 +55,10 @@ export function Confirmation({ lines: rawLines, startTs, totalOre, address = nul
54
55
  const singleCard = first ? (_jsx("p", { className: slotClass(classNames, 'card', 'rounded-lg border border-border bg-card px-5 py-4 font-medium'), children: joinLabelParts([
55
56
  first.item.bookedForName
56
57
  ? renderLabel(labels['confirmation.serviceFor'], {
57
- service: first.item.service.name,
58
+ service: visitName(first.item),
58
59
  name: first.item.bookedForName,
59
60
  })
60
- : first.item.service.name,
61
+ : visitName(first.item),
61
62
  first.stylistName,
62
63
  renderLabel(labels['confirmation.when'], {
63
64
  day: clock.dayLabel(startTs, now),
@@ -82,7 +83,7 @@ function PartyCard({ lines, totalOre, now, format, labels, classNames, PartyLine
82
83
  const text = fillLabel(labels['confirmation.party.line'], {
83
84
  time,
84
85
  name: line.item.bookedForName ?? '',
85
- service: line.item.service.name,
86
+ service: visitName(line.item),
86
87
  stylist: line.stylistName
87
88
  ? fillLabel(labels['confirmation.party.stylist'], { name: line.stylistName })
88
89
  : '',
@@ -94,8 +95,8 @@ function PartyCard({ lines, totalOre, now, format, labels, classNames, PartyLine
94
95
  .trim();
95
96
  return (_jsx(PartyLine
96
97
  // biome-ignore lint/suspicious/noArrayIndexKey: two lines can share a service; the index is the identity
97
- , { line: line, text: text, manageLabel: labels['confirmation.manage'], manageAriaLabel: fillLabel(labels['confirmation.manageFor'], {
98
- who: line.item.bookedForName ?? line.item.service.name,
98
+ , { line: line, text: text, manageLabel: labelText(labels['confirmation.manage']), manageAriaLabel: fillLabel(labels['confirmation.manageFor'], {
99
+ who: line.item.bookedForName ?? visitName(line.item),
99
100
  time,
100
101
  }), linkClassName: slotClass(classNames, 'link', 'text-sm font-semibold text-primary underline underline-offset-4') }, index));
101
102
  }) }), _jsx("p", { className: "font-semibold", children: renderLabel(labels['confirmation.party.total'], { total: format.price(totalOre) }) })] }));
@@ -1,5 +1,6 @@
1
1
  import { type ComponentType } from 'react';
2
2
  import type { BookingFormat } from './format.js';
3
+ import { type BookingLabel } from './labels.js';
3
4
  import { type SlotClassNames } from './slots.js';
4
5
  import type { BookingFamilyMember, WizardAction, WizardError, WizardState } from './types.js';
5
6
  /**
@@ -14,7 +15,7 @@ import type { BookingFamilyMember, WizardAction, WizardError, WizardState } from
14
15
  */
15
16
  export declare const DETAILS_SCREEN_LABEL_KEYS: readonly ["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"];
16
17
  export type DetailsScreenLabelKey = (typeof DETAILS_SCREEN_LABEL_KEYS)[number];
17
- export type DetailsScreenLabels = Record<DetailsScreenLabelKey, string>;
18
+ export type DetailsScreenLabels = Record<DetailsScreenLabelKey, BookingLabel>;
18
19
  /**
19
20
  * The label key for each failure the wizard can carry. Exported because some
20
21
  * of them (e.g. `maxParty`) are raised on earlier steps, where the shell shows
@@ -23,7 +24,10 @@ export type DetailsScreenLabels = Record<DetailsScreenLabelKey, string>;
23
24
  export declare const DETAILS_ERROR_LABEL_KEYS: Record<WizardError, DetailsScreenLabelKey>;
24
25
  /** One line of the submission. Optional fields are ABSENT, never blank. */
25
26
  export interface BookingSubmissionItem {
27
+ /** The person's first service. */
26
28
  serviceId: string;
29
+ /** The services after the first, in order, in the same visit. ABSENT for a one-service visit. */
30
+ extraServiceIds?: string[];
27
31
  resourceId?: string;
28
32
  startTs: number;
29
33
  bookedForName?: string;
@@ -7,7 +7,8 @@ import { Input } from '../primitives/input.js';
7
7
  import { Textarea } from '../primitives/textarea.js';
8
8
  import { renderLabel } from './internal/label-parts.js';
9
9
  import { BOOKING_CHECKBOX_CLASS, BOOKING_INPUT_CLASS, BOOKING_TEXTAREA_CLASS, BookingButton, } from './internal/ui.js';
10
- import { fillLabel } from './labels.js';
10
+ import { visitName } from './internal/visit.js';
11
+ import { fillLabel, labelText } from './labels.js';
11
12
  import { slotClass } from './slots.js';
12
13
  /**
13
14
  * The last step of the booking wizard: contact details, who each seat is for,
@@ -154,10 +155,10 @@ function knownWho(state, index, labels) {
154
155
  const item = state.items[index];
155
156
  if (item?.adult) {
156
157
  return state.people[index]?.key === 'self'
157
- ? labels['details.known.self']
158
- : labels['details.known.adult'];
158
+ ? labelText(labels['details.known.self'])
159
+ : labelText(labels['details.known.adult']);
159
160
  }
160
- return item?.bookedForName ?? labels['details.known.child'];
161
+ return item?.bookedForName ?? labelText(labels['details.known.child']);
161
162
  }
162
163
  /**
163
164
  * Whether step 1 already said who sits in this seat: a saved person's card
@@ -200,7 +201,8 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
200
201
  const showEmailError = !emailValid && (attempted || focused !== 'email');
201
202
  const showTermsError = attempted && !state.consentTerms;
202
203
  const price = format.price(totalOre);
203
- const phonePrefix = labels['details.phone.prefix'];
204
+ // `null` when the pack leaves it blank, in either form: no box, no description.
205
+ const phonePrefix = renderLabel(labels['details.phone.prefix']);
204
206
  const FamilyChip = components?.FamilyChip ?? DefaultFamilyChip;
205
207
  function setContact(name, value) {
206
208
  onChange({ type: 'setContact', field: name, value });
@@ -242,6 +244,9 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
242
244
  const resourceId = line?.resourceId ?? null;
243
245
  return {
244
246
  serviceId: item.service.id,
247
+ ...(item.extraServices && item.extraServices.length > 0
248
+ ? { extraServiceIds: item.extraServices.map((service) => service.id) }
249
+ : {}),
245
250
  ...(resourceId === null ? {} : { resourceId }),
246
251
  startTs: line?.startTs ?? startTs,
247
252
  ...present('bookedForName', isKnownChair(state, index) ? (item.bookedForName ?? '') : child.name),
@@ -286,11 +291,11 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
286
291
  }
287
292
  const inputClass = (...extra) => slotClass(classNames, 'input', BOOKING_INPUT_CLASS, ...extra);
288
293
  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) =>
294
+ 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": labelText(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: visitName(item) }), ' ', renderLabel(labels['details.known.for'], { who: knownWho(state, index, labels) })] }, index)) : null) })), state.items.map((item, index) =>
290
295
  // A seat step 1 already named is not asked again: a second answer could disagree.
291
296
  isKnownChair(state, index) ? null : (_jsx(ChildFields
292
297
  // 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 }) })] }));
298
+ , { legend: state.items.length > 1 ? visitName(item) : 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: labelText(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
299
  }
295
300
  /**
296
301
  * The name and birth year for one line. Wrapped in a fieldset only when there
@@ -298,7 +303,7 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
298
303
  * them is how one child's name lands on the other's booking.
299
304
  */
300
305
  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 }) })] })] }));
306
+ 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: labelText(labels['details.child.yearPlaceholder']), value: value.birthYear, onChange: (event) => onChange({ birthYear: event.target.value }) })] })] }));
302
307
  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
308
  if (legend === null)
304
309
  return withSuggestions;
@@ -3,10 +3,10 @@ export * from './booking-skeleton.js';
3
3
  export * from './confirmation.js';
4
4
  export * from './details-screen.js';
5
5
  export type { BookingClock, BookingDaypart, BookingFormat } from './format.js';
6
- export { joinLabelParts, labelParts, renderLabel } from './internal/label-parts.js';
6
+ export { type JoinLabelPartsOptions, joinLabelParts, labelParts, renderLabel, } from './internal/label-parts.js';
7
7
  export * from './internal/ui.js';
8
8
  export * from './label-keys.js';
9
- export { fillLabel } from './labels.js';
9
+ export { type BookingLabel, fillLabel, fillLabelPieces, labelText } from './labels.js';
10
10
  export * from './live-status.js';
11
11
  export * from './login-panel.js';
12
12
  export * from './login-sheet.js';
@@ -10,10 +10,10 @@ export * from './add-child-sheet.js';
10
10
  export * from './booking-skeleton.js';
11
11
  export * from './confirmation.js';
12
12
  export * from './details-screen.js';
13
- export { joinLabelParts, labelParts, renderLabel } from './internal/label-parts.js';
13
+ export { joinLabelParts, labelParts, renderLabel, } from './internal/label-parts.js';
14
14
  export * from './internal/ui.js';
15
15
  export * from './label-keys.js';
16
- export { fillLabel } from './labels.js';
16
+ export { fillLabel, fillLabelPieces, labelText } from './labels.js';
17
17
  export * from './live-status.js';
18
18
  export * from './login-panel.js';
19
19
  export * from './login-sheet.js';
@@ -1,28 +1,43 @@
1
1
  import { type ReactNode } from 'react';
2
+ import { type BookingLabel } from '../labels.js';
2
3
  /**
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.
4
+ * A filled label as element children (see `BookingLabel`): a string renders
5
+ * as one text node, an array as one text node per element. Prefer it over
6
+ * `fillLabel` wherever a label is rendered as children, so the pack decides
7
+ * where the sentence breaks.
8
+ *
9
+ * Returns the string itself for a one-node label, an array of strings for a
10
+ * split one, and `null` for a label that fills to nothing at all. Text-only
11
+ * targets (`aria-label`, `title`, a file name) keep `fillLabel`.
7
12
  */
8
- export declare function labelParts(template: string, parts: Readonly<Record<string, ReactNode>>): ReactNode;
13
+ export declare function renderLabel(template: BookingLabel, values?: Readonly<Record<string, string | number>>): string | string[] | null;
9
14
  /**
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`.
15
+ * `renderLabel` for templates whose placeholders include elements (a link, a
16
+ * button) rather than strings: `'Have an account? {trigger} – …'` with
17
+ * `{ trigger: <button>…</button> }`. Each element sits in its own place; the
18
+ * text around it follows the label's form — a string's text runs stay whole
19
+ * between elements, an array's elements stay separate. String and number
20
+ * values join the text they sit in. Unknown placeholders are left as written.
21
21
  */
22
- export declare function renderLabel(template: string, values: Readonly<Record<string, string | number>>): ReactNode;
22
+ export declare function labelParts(template: BookingLabel, parts: Readonly<Record<string, ReactNode>>): ReactNode;
23
+ export interface JoinLabelPartsOptions {
24
+ /**
25
+ * `false`: the line is ONE text node (every part is flattened to text and
26
+ * joined). `true`: every part and every separator is its own text node, and
27
+ * a split part (an array, from a `string[]` label) keeps its pieces.
28
+ *
29
+ * Default: one text node when every part is plain text, pieces as soon as
30
+ * one part is split (a pack wrote one of the line's labels as an array) or
31
+ * is an element. A part that cannot be flattened (an element) always
32
+ * renders in pieces.
33
+ */
34
+ pieces?: boolean;
35
+ }
23
36
  /**
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.
37
+ * `parts.filter(Boolean).join(separator)` as element children. Blank parts
38
+ * drop out, so an absent one closes the gap rather than leaving a doubled
39
+ * separator. Whether the line is one text node or one per part and separator
40
+ * follows the parts' own form (see `JoinLabelPartsOptions.pieces`): plain
41
+ * strings join into one, as `[a, b].join(' · ')` in JSX did.
27
42
  */
28
- export declare function joinLabelParts(parts: ReadonlyArray<ReactNode>, separator: string): ReactNode;
43
+ export declare function joinLabelParts(parts: ReadonlyArray<ReactNode>, separator: string, options?: JoinLabelPartsOptions): ReactNode;
@@ -1,57 +1,85 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Fragment } from 'react';
3
+ import { fillLabelPieces } from '../labels.js';
3
4
  /**
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.
5
+ * A filled label as element children (see `BookingLabel`): a string renders
6
+ * as one text node, an array as one text node per element. Prefer it over
7
+ * `fillLabel` wherever a label is rendered as children, so the pack decides
8
+ * where the sentence breaks.
9
+ *
10
+ * Returns the string itself for a one-node label, an array of strings for a
11
+ * split one, and `null` for a label that fills to nothing at all. Text-only
12
+ * targets (`aria-label`, `title`, a file name) keep `fillLabel`.
8
13
  */
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
- });
14
+ export function renderLabel(template, values = {}) {
15
+ const pieces = fillLabelPieces(template, values);
16
+ if (pieces.length === 0)
17
+ return null;
18
+ return typeof template === 'string' ? pieces[0] : pieces;
19
19
  }
20
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`.
21
+ * `renderLabel` for templates whose placeholders include elements (a link, a
22
+ * button) rather than strings: `'Have an account? {trigger} – …'` with
23
+ * `{ trigger: <button>…</button> }`. Each element sits in its own place; the
24
+ * text around it follows the label's form — a string's text runs stay whole
25
+ * between elements, an array's elements stay separate. String and number
26
+ * values join the text they sit in. Unknown placeholders are left as written.
32
27
  */
33
- export function renderLabel(template, values) {
34
- const pieces = template.split(/(\{\w+\})/g);
28
+ export function labelParts(template, parts) {
29
+ const pieces = typeof template === 'string' ? [template] : template;
35
30
  const nodes = [];
36
31
  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);
32
+ let run = '';
33
+ for (const token of piece.split(/(\{\w+\})/g)) {
34
+ const name = /^\{(\w+)\}$/.exec(token)?.[1];
35
+ const value = name !== undefined && Object.hasOwn(parts, name) ? parts[name] : token;
36
+ if (typeof value === 'string' || typeof value === 'number') {
37
+ run += String(value);
38
+ continue;
39
+ }
40
+ if (run !== '')
41
+ nodes.push(run);
42
+ run = '';
43
+ nodes.push(_jsx(Fragment, { children: value }, nodes.length));
44
+ }
45
+ if (run !== '')
46
+ nodes.push(run);
47
+ }
48
+ return nodes;
49
+ }
50
+ /** A part as plain text, or `null` when it holds an element. */
51
+ function partText(part) {
52
+ if (typeof part === 'string' || typeof part === 'number')
53
+ return String(part);
54
+ if (Array.isArray(part)) {
55
+ let text = '';
56
+ for (const piece of part) {
57
+ const pieceText = partText(piece);
58
+ if (pieceText === null)
59
+ return null;
60
+ text += pieceText;
61
+ }
62
+ return text;
41
63
  }
42
- return nodes.length > 0 ? nodes : null;
64
+ return null;
43
65
  }
44
66
  /**
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.
67
+ * `parts.filter(Boolean).join(separator)` as element children. Blank parts
68
+ * drop out, so an absent one closes the gap rather than leaving a doubled
69
+ * separator. Whether the line is one text node or one per part and separator
70
+ * follows the parts' own form (see `JoinLabelPartsOptions.pieces`): plain
71
+ * strings join into one, as `[a, b].join(' · ')` in JSX did.
48
72
  */
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)));
73
+ export function joinLabelParts(parts, separator, options = {}) {
74
+ // Blank = renders no text: nothing at all, or text pieces that are all empty.
75
+ const present = parts.filter((part) => part !== null && part !== undefined && typeof part !== 'boolean' && partText(part) !== '');
76
+ const pieces = options.pieces ?? present.some((part) => typeof part !== 'string' && typeof part !== 'number');
77
+ if (!pieces) {
78
+ const texts = present.map(partText);
79
+ if (texts.every((text) => text !== null)) {
80
+ const line = texts.join(separator);
81
+ return line === '' ? null : line;
82
+ }
83
+ }
84
+ return present.map((part, index) => (_jsxs(Fragment, { children: [index > 0 && separator, part] }, index)));
57
85
  }
@@ -0,0 +1,55 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { BookingFormat } from '../format.js';
3
+ import type { ServiceMultiLabels, ServicePartyPerson, ServiceScreenSlot, ServiceSelection } from '../service-screen.js';
4
+ import { type SlotClassNames } from '../slots.js';
5
+ /**
6
+ * The service step's multi-select parts (`ServiceScreenProps.selection`):
7
+ * the per-person tabs, the sticky total bar and the checkbox card. Internal —
8
+ * `ServiceScreen` composes them; the package does not export them.
9
+ */
10
+ export declare const serviceTabId: (index: number) => string;
11
+ export declare const serviceTabPanelId: (index: number) => string;
12
+ /**
13
+ * One tab per person on the multi-select step, a tick on everyone who has
14
+ * something. The WAI-ARIA tabs pattern: one tab stop, arrows move between
15
+ * people, Home / End jump to the ends; moving selects (one short panel, no
16
+ * cost to showing it).
17
+ */
18
+ export declare function PersonTabs({ people, active, done, onSelect, labels, labelledBy, describedBy, classNames, }: {
19
+ people: ReadonlyArray<ServicePartyPerson>;
20
+ active: number;
21
+ done: (index: number) => boolean;
22
+ onSelect: (index: number) => void;
23
+ labels: ServiceMultiLabels;
24
+ labelledBy: string;
25
+ describedBy: string;
26
+ classNames: SlotClassNames<ServiceScreenSlot> | undefined;
27
+ }): import("react").JSX.Element;
28
+ /**
29
+ * The multi-select step's sticky bar: the running total, what is still
30
+ * missing, and «Next». The same bar as `SummaryBar`, so the step reads as one
31
+ * flow; a caller that also renders `SummaryBar` hides it on this step.
32
+ *
33
+ * «Next» is never disabled. A disabled button explains nothing and is skipped
34
+ * by a screen reader's tab order; this one stays, the line says what is
35
+ * missing, and the caller decides what pressing it does.
36
+ */
37
+ export declare function TotalBar({ selection, format, classNames, }: {
38
+ selection: ServiceSelection;
39
+ format: BookingFormat;
40
+ classNames: SlotClassNames<ServiceScreenSlot> | undefined;
41
+ }): import("react").JSX.Element;
42
+ /**
43
+ * A service card on the multi-select step: a real checkbox inside its label.
44
+ * Space (and Enter) toggles it, the label's whole box is the target, and a
45
+ * screen reader hears «checkbox, checked».
46
+ */
47
+ export declare function MultiServiceCard({ name, selected, onPick, duration, price, className, selectedClassName, }: {
48
+ name: string;
49
+ selected: boolean;
50
+ onPick: () => void;
51
+ duration: ReactNode;
52
+ price: ReactNode;
53
+ className?: string;
54
+ selectedClassName?: string;
55
+ }): import("react").JSX.Element;