@medalsocial/meda 3.2.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.
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { joinLabelParts, renderLabel } from './internal/label-parts.js';
3
4
  import { BookingButton } from './internal/ui.js';
4
5
  import { fillLabel } from './labels.js';
5
6
  import { slotClass } from './slots.js';
@@ -50,24 +51,21 @@ export function Confirmation({ lines: rawLines, startTs, totalOre, address = nul
50
51
  }));
51
52
  const linkClass = slotClass(classNames, 'link', 'font-semibold text-primary underline underline-offset-4');
52
53
  const first = lines[0];
53
- const singleCard = first ? (_jsx("p", { className: slotClass(classNames, 'card', 'rounded-lg border border-border bg-card px-5 py-4 font-medium'), children: [
54
+ const singleCard = first ? (_jsx("p", { className: slotClass(classNames, 'card', 'rounded-lg border border-border bg-card px-5 py-4 font-medium'), children: joinLabelParts([
54
55
  first.item.bookedForName
55
- ? fillLabel(labels['confirmation.serviceFor'], {
56
+ ? renderLabel(labels['confirmation.serviceFor'], {
56
57
  service: first.item.service.name,
57
58
  name: first.item.bookedForName,
58
59
  })
59
60
  : first.item.service.name,
60
61
  first.stylistName,
61
- fillLabel(labels['confirmation.when'], {
62
+ renderLabel(labels['confirmation.when'], {
62
63
  day: clock.dayLabel(startTs, now),
63
64
  time: clock.formatTime(startTs),
64
65
  }),
65
66
  // This line's own price, which for one person is the whole total.
66
67
  format.price(first.priceOre),
67
- ]
68
- // Filtered, not conditionally joined: an absent stylist closes the gap.
69
- .filter((part) => Boolean(part))
70
- .join(SEPARATOR) })) : null;
68
+ ], SEPARATOR) })) : null;
71
69
  return (_jsxs("section", { "aria-labelledby": "booking-confirmed-heading", className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h2", { id: "booking-confirmed-heading", tabIndex: -1, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: labels['confirmation.heading'] }), lines.length > 1 ? (_jsx(PartyCard, { lines: lines, totalOre: totalOre, now: now, format: format, labels: labels, classNames: classNames, PartyLine: components?.PartyLine ?? DefaultPartyLine })) : (singleCard), address && (_jsx("p", { className: slotClass(classNames, 'address', 'text-sm text-muted-foreground'), children: address })), _jsxs("div", { className: "flex flex-wrap gap-4", children: [calendarHref && (_jsx("a", { href: calendarHref, download: calendarFileName, className: linkClass, children: labels['confirmation.calendar'] })), lines.length === 1 && first?.manageHref && (_jsx("a", { href: first.manageHref, className: linkClass, children: labels['confirmation.manage'] }))] }), portalHref && (_jsx("p", { className: "text-sm", children: _jsx("a", { href: portalHref, className: linkClass, children: labels['confirmation.portal'] }) })), onStartOver && (_jsx("div", { children: _jsx(BookingButton, { variant: "outline", className: slotClass(classNames, 'startOver'), onClick: onStartOver, children: labels['confirmation.startOver'] }) }))] }));
72
70
  }
73
71
  /**
@@ -77,7 +75,9 @@ export function Confirmation({ lines: rawLines, startTs, totalOre, address = nul
77
75
  function PartyCard({ lines, totalOre, now, format, labels, classNames, PartyLine, }) {
78
76
  const { clock } = format;
79
77
  const firstStart = lines[0]?.startTs ?? 0;
80
- return (_jsxs("div", { className: slotClass(classNames, 'partyCard', 'space-y-2 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("p", { className: "font-semibold", children: fillLabel(labels['confirmation.party.heading'], { day: clock.dayLabel(firstStart, now) }) }), _jsx("ul", { className: "space-y-1", children: lines.map((line, index) => {
78
+ return (_jsxs("div", { className: slotClass(classNames, 'partyCard', 'space-y-2 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("p", { className: "font-semibold", children: renderLabel(labels['confirmation.party.heading'], {
79
+ day: clock.dayLabel(firstStart, now),
80
+ }) }), _jsx("ul", { className: "space-y-1", children: lines.map((line, index) => {
81
81
  const time = clock.formatTime(line.startTs);
82
82
  const text = fillLabel(labels['confirmation.party.line'], {
83
83
  time,
@@ -98,5 +98,5 @@ function PartyCard({ lines, totalOre, now, format, labels, classNames, PartyLine
98
98
  who: line.item.bookedForName ?? line.item.service.name,
99
99
  time,
100
100
  }), linkClassName: slotClass(classNames, 'link', 'text-sm font-semibold text-primary underline underline-offset-4') }, index));
101
- }) }), _jsx("p", { className: "font-semibold", children: fillLabel(labels['confirmation.party.total'], { total: format.price(totalOre) }) })] }));
101
+ }) }), _jsx("p", { className: "font-semibold", children: renderLabel(labels['confirmation.party.total'], { total: format.price(totalOre) }) })] }));
102
102
  }
@@ -5,6 +5,7 @@ import { Checkbox } from '../primitives/checkbox.js';
5
5
  import { Field } from '../primitives/field.js';
6
6
  import { Input } from '../primitives/input.js';
7
7
  import { Textarea } from '../primitives/textarea.js';
8
+ import { renderLabel } from './internal/label-parts.js';
8
9
  import { BOOKING_CHECKBOX_CLASS, BOOKING_INPUT_CLASS, BOOKING_TEXTAREA_CLASS, BookingButton, } from './internal/ui.js';
9
10
  import { fillLabel } from './labels.js';
10
11
  import { slotClass } from './slots.js';
@@ -285,11 +286,11 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
285
286
  }
286
287
  const inputClass = (...extra) => slotClass(classNames, 'input', BOOKING_INPUT_CLASS, ...extra);
287
288
  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
+ 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) =>
289
290
  // A seat step 1 already named is not asked again: a second answer could disagree.
290
291
  isKnownChair(state, index) ? null : (_jsx(ChildFields
291
292
  // 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
+ , { 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 }) })] }));
293
294
  }
294
295
  /**
295
296
  * The name and birth year for one line. Wrapped in a fieldset only when there
@@ -309,6 +310,7 @@ function ChildFields({ legend, nameId, yearId, value, family, labels, classNames
309
310
  * visitor cannot fix by trying harder.
310
311
  */
311
312
  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'])] }));
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'])] }));
314
316
  }
@@ -3,6 +3,7 @@ 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
7
  export * from './internal/ui.js';
7
8
  export * from './label-keys.js';
8
9
  export { fillLabel } from './labels.js';
@@ -10,6 +10,7 @@ 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
14
  export * from './internal/ui.js';
14
15
  export * from './label-keys.js';
15
16
  export { fillLabel } from './labels.js';
@@ -6,3 +6,23 @@ import { type ReactNode } from 'react';
6
6
  * placeholders are left as written.
7
7
  */
8
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;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Fragment } from 'react';
3
3
  /**
4
4
  * `fillLabel` for templates whose placeholders are elements (a link, a
@@ -17,3 +17,41 @@ export function labelParts(template, parts) {
17
17
  return piece;
18
18
  });
19
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
+ }
@@ -3,8 +3,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { useEffect, useId, useRef, useState, useTransition, } from 'react';
4
4
  import { Field } from '../primitives/field.js';
5
5
  import { Input } from '../primitives/input.js';
6
+ import { renderLabel } from './internal/label-parts.js';
6
7
  import { BOOKING_INPUT_CLASS, BookingButton } from './internal/ui.js';
7
- import { fillLabel } from './labels.js';
8
8
  import { OTP_SLOTS_LABEL_KEYS, OtpSlots, } from './otp-slots.js';
9
9
  import { slotClass } from './slots.js';
10
10
  import { VIPPS_BUTTON_LABEL_KEYS, VippsButton, } from './vipps-button.js';
@@ -221,19 +221,19 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
221
221
  // The countdown on the button changes every second; this region says only
222
222
  // the two things worth hearing — how long, and that it is over.
223
223
  const cooldownSentence = waitSeconds > 0
224
- ? fillLabel(labels['login.cooldownWait'], { seconds: Math.ceil(resendCooldownMs / 1000) })
224
+ ? renderLabel(labels['login.cooldownWait'], { seconds: Math.ceil(resendCooldownMs / 1000) })
225
225
  : labels['login.cooldownReady'];
226
226
  const cooldownRegion = (_jsx("p", { "aria-live": "polite", className: "sr-only", children: mode === 'code' ? cooldownSentence : null }));
227
227
  const link = slotClass(classNames, 'link', 'font-semibold text-primary underline underline-offset-4 disabled:no-underline disabled:opacity-60');
228
228
  return (_jsxs("div", { className: slotClass(classNames, 'root', 'space-y-6'), children: [heading(mode === 'start' ? labels['login.heading'] : labels['login.codeHeading']), mode === 'vipps' && (_jsx("p", { className: "text-muted-foreground", children: sentTo === ''
229
229
  ? labels['login.vippsSentToUnknown']
230
- : fillLabel(labels['login.vippsSentTo'], { to: sentTo }) })), liveRegion, cooldownRegion, mode === 'start' ? (_jsxs(_Fragment, { children: [intro, onVipps && (_jsxs(_Fragment, { children: [_jsx(VippsButton, { onVipps: onVipps, next: vippsNext, label: vippsLabel, labels: labels, classNames: vippsClassNames }), _jsxs("div", { className: slotClass(classNames, 'divider', 'flex items-center gap-4 text-sm text-muted-foreground'), "aria-hidden": "true", children: [_jsx("span", { className: "h-px flex-1 bg-border" }), labels['login.or'], _jsx("span", { className: "h-px flex-1 bg-border" })] })] })), _jsx("p", { className: "text-muted-foreground", children: labels['login.emailIntro'] }), _jsxs("form", { onSubmit: submitEmail, className: slotClass(classNames, 'form', 'space-y-6'), children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: emailId, children: labels['login.emailLabel'] }), _jsx(Input, { id: emailId, type: "email", autoComplete: "email", required: true, value: email, "aria-describedby": noticeText ? noticeId : undefined, "aria-invalid": isFieldError || undefined, onChange: (event) => setEmail(event.target.value), className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS) })] }), _jsx(BookingButton, { type: "submit", size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: pending, children: labels['login.sendCode'] })] })] })) : (_jsxs(_Fragment, { children: [_jsxs("form", { onSubmit: submitCode, className: slotClass(classNames, 'form', 'space-y-6'), children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: codeId, children: labels['login.codeLabel'] }), _jsx("div", { ref: codeRef, children: _jsx(OtpSlots, { id: codeId, value: code,
230
+ : renderLabel(labels['login.vippsSentTo'], { to: sentTo }) })), liveRegion, cooldownRegion, mode === 'start' ? (_jsxs(_Fragment, { children: [intro, onVipps && (_jsxs(_Fragment, { children: [_jsx(VippsButton, { onVipps: onVipps, next: vippsNext, label: vippsLabel, labels: labels, classNames: vippsClassNames }), _jsxs("div", { className: slotClass(classNames, 'divider', 'flex items-center gap-4 text-sm text-muted-foreground'), "aria-hidden": "true", children: [_jsx("span", { className: "h-px flex-1 bg-border" }), labels['login.or'], _jsx("span", { className: "h-px flex-1 bg-border" })] })] })), _jsx("p", { className: "text-muted-foreground", children: labels['login.emailIntro'] }), _jsxs("form", { onSubmit: submitEmail, className: slotClass(classNames, 'form', 'space-y-6'), children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: emailId, children: labels['login.emailLabel'] }), _jsx(Input, { id: emailId, type: "email", autoComplete: "email", required: true, value: email, "aria-describedby": noticeText ? noticeId : undefined, "aria-invalid": isFieldError || undefined, onChange: (event) => setEmail(event.target.value), className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS) })] }), _jsx(BookingButton, { type: "submit", size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: pending, children: labels['login.sendCode'] })] })] })) : (_jsxs(_Fragment, { children: [_jsxs("form", { onSubmit: submitCode, className: slotClass(classNames, 'form', 'space-y-6'), children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: codeId, children: labels['login.codeLabel'] }), _jsx("div", { ref: codeRef, children: _jsx(OtpSlots, { id: codeId, value: code,
231
231
  // Read-only rather than disabled while a request is out, so
232
232
  // the caret stays in the field instead of falling out of a
233
233
  // dialog; a wrong code then only has to empty it.
234
234
  readOnly: checking, invalid: isFieldError, describedBy: `${codeId}-help${noticeText ? ` ${noticeId}` : ''}`, labels: labels, classNames: otpClassNames, onChange: setCode, onComplete: verify }) }), _jsx(Field.Description, { id: `${codeId}-help`, children: mode === 'vipps'
235
235
  ? labels['login.codeHelpVipps']
236
- : fillLabel(labels['login.codeHelp'], { email: sentTo }) })] }), _jsx(BookingButton, { type: "submit", size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: pending, children: labels['login.submitCode'] })] }), _jsxs("div", { className: slotClass(classNames, 'links', 'flex flex-wrap items-center gap-4 text-sm'), children: [mode !== 'vipps' && (_jsx("button", { type: "button", disabled: pending || waitSeconds > 0, onClick: () => requestCode(sentTo, true), className: link, children: waitSeconds > 0
237
- ? fillLabel(labels['login.resendIn'], { time: timer(waitSeconds) })
236
+ : renderLabel(labels['login.codeHelp'], { email: sentTo }) })] }), _jsx(BookingButton, { type: "submit", size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: pending, children: labels['login.submitCode'] })] }), _jsxs("div", { className: slotClass(classNames, 'links', 'flex flex-wrap items-center gap-4 text-sm'), children: [mode !== 'vipps' && (_jsx("button", { type: "button", disabled: pending || waitSeconds > 0, onClick: () => requestCode(sentTo, true), className: link, children: waitSeconds > 0
237
+ ? renderLabel(labels['login.resendIn'], { time: timer(waitSeconds) })
238
238
  : labels['login.resend'] })), _jsx("button", { type: "button", disabled: pending, onClick: switchEmail, className: link, children: mode === 'vipps' ? labels['login.useEmailInstead'] : labels['login.switchEmail'] })] })] }))] }));
239
239
  }
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useEffect, useState } from 'react';
4
4
  import { cn } from '../lib/utils.js';
5
+ import { renderLabel } from './internal/label-parts.js';
5
6
  import { BookingButton } from './internal/ui.js';
6
7
  import { fillLabel } from './labels.js';
7
8
  import { slotClass } from './slots.js';
@@ -271,14 +272,14 @@ export function ManageScreen({ labels, format, dayparts, booking, phone = null,
271
272
  const panelClass = (base) => slotClass(classNames, 'panel', base);
272
273
  return (_jsxs("section", { "aria-labelledby": "booking-manage-heading", className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h1", { id: "booking-manage-heading", className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold md:text-3xl'), children: labels['manage.heading'] }), error !== null && (_jsx("p", { role: "alert", className: slotClass(classNames, 'alert', 'rounded-lg border border-secondary/40 bg-secondary/10 px-5 py-3 text-sm'), children: errorSentence(labels, error, phone) })), view === 'cancelled' && (_jsxs("div", { className: panelClass('space-y-4 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("p", { className: "font-medium", children: labels['manage.cancelled'] }), _jsx("a", { href: bookingHref, className: LINK_CLASS, children: labels['manage.cancelled.findNew'] })] })), view === 'moved' && (_jsxs("div", { className: panelClass('space-y-4 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("p", { className: "font-medium", children: movedTo === null
273
274
  ? labels['manage.moved']
274
- : fillLabel(labels['manage.movedTo'], {
275
+ : renderLabel(labels['manage.movedTo'], {
275
276
  dateTime: format.clock.dateTime(movedTo),
276
277
  }) }), newManageHref ? (_jsx("a", { href: newManageHref, className: LINK_CLASS, children: labels['manage.moved.view'] })) : (_jsxs("p", { className: "text-sm text-muted-foreground", children: [labels['manage.moved.noLink'], ' ', _jsx(PhoneSentence, { format: format, phone: phone, children: callUs(labels, phone) }), ' ', labels['manage.moved.noLinkSuffix']] }))] })), view !== 'cancelled' && view !== 'moved' && (_jsxs(_Fragment, { children: [_jsxs("div", { className: slotClass(classNames, 'card', 'space-y-1 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("p", { className: "font-semibold", children: booking.bookedForName
277
- ? fillLabel(labels['manage.card.named'], {
278
+ ? renderLabel(labels['manage.card.named'], {
278
279
  name: booking.bookedForName,
279
280
  service: serviceLabel,
280
281
  })
281
- : serviceLabel }), _jsx("p", { className: "tabular-nums", children: fillLabel(labels[stylist ? 'manage.card.whenWithStylist' : 'manage.card.when'], {
282
+ : serviceLabel }), _jsx("p", { className: "tabular-nums", children: renderLabel(labels[stylist ? 'manage.card.whenWithStylist' : 'manage.card.when'], {
282
283
  dateTime: format.clock.dateTime(booking.startTs),
283
284
  stylist: stylist ?? '',
284
285
  price: format.price(booking.amountOre),
@@ -287,11 +288,11 @@ export function ManageScreen({ labels, format, dayparts, booking, phone = null,
287
288
  setError(null);
288
289
  setView('cancel');
289
290
  } })) : (_jsx(ClosedBooking, { labels: labels, format: format, status: booking.status, bookingHref: bookingHref, phone: phone, className: panelClass('space-y-3 rounded-lg border border-border bg-card px-5 py-4') }))), view === 'party' && (_jsx(PartyChoice, { labels: labels, format: format, bookedForName: booking.bookedForName, phone: phone, onJustThisOne: goToTime, onBack: () => setView('overview'), className: panelClass('space-y-4 rounded-lg border border-primary/40 bg-primary/5 px-5 py-4') })), view === 'time' && (_jsxs("div", { className: "space-y-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: stylist
290
- ? fillLabel(labels['manage.reschedule.leadWithStylist'], {
291
+ ? renderLabel(labels['manage.reschedule.leadWithStylist'], {
291
292
  service: serviceLabel,
292
293
  stylist,
293
294
  })
294
- : fillLabel(labels['manage.reschedule.lead'], { service: serviceLabel }) }), slotsFailed && (_jsxs("p", { className: "rounded-lg border border-border bg-card px-5 py-4 text-sm", children: [labels['manage.reschedule.failed'], ' ', _jsx(PhoneSentence, { format: format, phone: phone, children: ringUs(labels, phone) }), labels['manage.reschedule.failedSuffix']] })), !slotsFailed && slots === null && (_jsx("p", { className: "text-sm text-muted-foreground", children: labels['manage.reschedule.loading'] })), !slotsFailed && slots !== null && (_jsx(TimeScreen, { labels: labels, format: format, dayparts: dayparts, slots: slots, days: reschedule.days, openDays: reschedule.openDays ?? null, currentSlotTs: booking.startTs, weekendNote: weekendNoteFor(pricing), phone: phone, now: nowProp, classNames: timeClassNames, components: components, onPick: (slot) => {
295
+ : renderLabel(labels['manage.reschedule.lead'], { service: serviceLabel }) }), slotsFailed && (_jsxs("p", { className: "rounded-lg border border-border bg-card px-5 py-4 text-sm", children: [labels['manage.reschedule.failed'], ' ', _jsx(PhoneSentence, { format: format, phone: phone, children: ringUs(labels, phone) }), labels['manage.reschedule.failedSuffix']] })), !slotsFailed && slots === null && (_jsx("p", { className: "text-sm text-muted-foreground", children: labels['manage.reschedule.loading'] })), !slotsFailed && slots !== null && (_jsx(TimeScreen, { labels: labels, format: format, dayparts: dayparts, slots: slots, days: reschedule.days, openDays: reschedule.openDays ?? null, currentSlotTs: booking.startTs, weekendNote: weekendNoteFor(pricing), phone: phone, now: nowProp, classNames: timeClassNames, components: components, onPick: (slot) => {
295
296
  setPending(slot);
296
297
  setError(null);
297
298
  setView('move');
@@ -311,13 +312,15 @@ function Overview({ labels, format, booking, freeUntilTs, remaining, phone, icsH
311
312
  // The largest window that has already closed — the number the sentence below
312
313
  // quotes. Zero when both are open, and then it is not rendered.
313
314
  const blockedHours = Math.max(booking.canCancel ? 0 : booking.cancelWindowHours, booking.canReschedule ? 0 : booking.rescheduleWindowHours);
314
- return (_jsxs(_Fragment, { children: [booking.canCancel && booking.canReschedule && (_jsxs("p", { className: "text-sm text-muted-foreground", children: [fillLabel(labels['manage.freeUntil'], { dateTime: format.clock.dateTime(freeUntilTs) }), remaining !== null && _jsxs("span", { children: [" (", remaining, ")"] })] })), _jsxs("div", { className: cn('flex flex-wrap items-center gap-4', actionsClassName), children: [booking.canReschedule ? (_jsx(BookingButton, { size: "lg", onClick: onChange, children: labels['manage.change'] })) : (_jsx(PhoneSentence, { format: format, phone: phone, children: fillLabel(labels['manage.callToChange'], { hours: booking.rescheduleWindowHours }) })), booking.canCancel ? (_jsx(BookingButton, { size: "lg", variant: "outline", onClick: onCancel, children: labels['manage.cancel'] })) : (_jsx(PhoneSentence, { format: format, phone: phone, children: fillLabel(labels['manage.callToCancel'], { hours: booking.cancelWindowHours }) })), icsHref !== null && (_jsx("a", { href: icsHref, download: icsFileName ?? true, className: LINK_CLASS, children: labels['manage.calendar'] }))] }), blockedHours > 0 && (_jsxs("p", { className: "rounded-lg border border-border bg-card px-5 py-4 text-sm", children: [fillLabel(labels['manage.blocked'], { hours: blockedHours }), ' ', phone ? (_jsxs(_Fragment, { children: [_jsx(PhoneSentence, { format: format, phone: phone, children: fillLabel(labels['manage.ringUsOn'], { phone }) }), labels['manage.blocked.suffix']] })) : (labels['manage.blocked.plain'])] }))] }));
315
+ return (_jsxs(_Fragment, { children: [booking.canCancel && booking.canReschedule && (_jsxs("p", { className: "text-sm text-muted-foreground", children: [renderLabel(labels['manage.freeUntil'], {
316
+ dateTime: format.clock.dateTime(freeUntilTs),
317
+ }), remaining !== null && _jsxs("span", { children: [" (", remaining, ")"] })] })), _jsxs("div", { className: cn('flex flex-wrap items-center gap-4', actionsClassName), children: [booking.canReschedule ? (_jsx(BookingButton, { size: "lg", onClick: onChange, children: labels['manage.change'] })) : (_jsx(PhoneSentence, { format: format, phone: phone, children: renderLabel(labels['manage.callToChange'], { hours: booking.rescheduleWindowHours }) })), booking.canCancel ? (_jsx(BookingButton, { size: "lg", variant: "outline", onClick: onCancel, children: labels['manage.cancel'] })) : (_jsx(PhoneSentence, { format: format, phone: phone, children: renderLabel(labels['manage.callToCancel'], { hours: booking.cancelWindowHours }) })), icsHref !== null && (_jsx("a", { href: icsHref, download: icsFileName ?? true, className: LINK_CLASS, children: labels['manage.calendar'] }))] }), blockedHours > 0 && (_jsxs("p", { className: "rounded-lg border border-border bg-card px-5 py-4 text-sm", children: [renderLabel(labels['manage.blocked'], { hours: blockedHours }), ' ', phone ? (_jsxs(_Fragment, { children: [_jsx(PhoneSentence, { format: format, phone: phone, children: renderLabel(labels['manage.ringUsOn'], { phone }) }), labels['manage.blocked.suffix']] })) : (labels['manage.blocked.plain'])] }))] }));
315
318
  }
316
319
  /** A booking no longer open to either action, saying which kind it is. */
317
320
  function ClosedBooking({ labels, format, status, bookingHref, phone, className, }) {
318
321
  const sentence = labels[CLOSED_SENTENCE[status] ?? 'manage.closed.pending'];
319
322
  return (_jsxs("div", { className: className, children: [_jsx("p", { className: "text-sm", children: sentence }), status === 'pending' ? (_jsx("p", { className: "text-sm", children: _jsx(PhoneSentence, { format: format, phone: phone, children: phone
320
- ? fillLabel(labels['manage.closed.pendingCall'], { phone })
323
+ ? renderLabel(labels['manage.closed.pendingCall'], { phone })
321
324
  : labels['manage.closed.pendingCallPlain'] }) })) : (_jsx("a", { href: bookingHref, className: LINK_CLASS, children: labels['manage.bookNew'] }))] }));
322
325
  }
323
326
  /**
@@ -328,7 +331,7 @@ function ClosedBooking({ labels, format, status, bookingHref, phone, className,
328
331
  */
329
332
  function PartyChoice({ labels, format, bookedForName, phone, onJustThisOne, onBack, className, }) {
330
333
  const who = bookedForName ?? labels['manage.party.unnamed'];
331
- return (_jsxs("div", { className: className, children: [_jsx("h2", { className: "font-sans text-lg font-bold", children: fillLabel(labels['manage.party.heading'], { who }) }), _jsxs("div", { className: "flex flex-wrap items-center gap-4", children: [_jsx(BookingButton, { size: "lg", onClick: onJustThisOne, children: fillLabel(labels['manage.party.justThisOne'], { who }) }), _jsx(BookingButton, { size: "lg", variant: "outline", onClick: onBack, children: labels['manage.back'] })] }), _jsxs("p", { className: "text-sm text-muted-foreground", children: [labels['manage.party.note'], ' ', _jsx(PhoneSentence, { format: format, phone: phone, children: ringUs(labels, phone) }), labels['manage.party.noteSuffix']] })] }));
334
+ return (_jsxs("div", { className: className, children: [_jsx("h2", { className: "font-sans text-lg font-bold", children: renderLabel(labels['manage.party.heading'], { who }) }), _jsxs("div", { className: "flex flex-wrap items-center gap-4", children: [_jsx(BookingButton, { size: "lg", onClick: onJustThisOne, children: renderLabel(labels['manage.party.justThisOne'], { who }) }), _jsx(BookingButton, { size: "lg", variant: "outline", onClick: onBack, children: labels['manage.back'] })] }), _jsxs("p", { className: "text-sm text-muted-foreground", children: [labels['manage.party.note'], ' ', _jsx(PhoneSentence, { format: format, phone: phone, children: ringUs(labels, phone) }), labels['manage.party.noteSuffix']] })] }));
332
335
  }
333
336
  /**
334
337
  * The single confirm for a move. The weekend note appears only when the new
@@ -340,13 +343,13 @@ function ConfirmMove({ labels, format, booking, pricing, stylist, pending, busy,
340
343
  const nextOre = pricing ? pricing.priceAt(pending.startTs) : 0;
341
344
  const nowOre = pricing ? pricing.priceAt(booking.startTs) : 0;
342
345
  const dearer = pricing !== null && nextOre > nowOre;
343
- const question = fillLabel(labels[stylist ? 'manage.move.questionWithStylist' : 'manage.move.question'], {
346
+ const question = renderLabel(labels[stylist ? 'manage.move.questionWithStylist' : 'manage.move.question'], {
344
347
  appointment: theirAppointment(labels, booking.bookedForName),
345
348
  from: `${clock.date(booking.startTs)} ${clock.formatTime(booking.startTs)}`,
346
349
  to: `${clock.date(pending.startTs)} ${clock.formatTime(pending.startTs)}`,
347
350
  stylist: stylist ?? '',
348
351
  });
349
- return (_jsxs("div", { className: className, children: [_jsx("p", { className: "font-medium", children: question }), dearer && pricing && (_jsx("p", { className: "text-sm", children: fillLabel(labels['manage.move.surcharge'], {
352
+ return (_jsxs("div", { className: className, children: [_jsx("p", { className: "font-medium", children: question }), dearer && pricing && (_jsx("p", { className: "text-sm", children: renderLabel(labels['manage.move.surcharge'], {
350
353
  weekday: clock.weekday(pending.startTs),
351
354
  price: format.price(nextOre),
352
355
  pct: pricing.weekendSurchargePct,
@@ -354,7 +357,7 @@ function ConfirmMove({ labels, format, booking, pricing, stylist, pending, busy,
354
357
  }
355
358
  /** The single confirm for a cancellation, with the reason asked and never required. */
356
359
  function ConfirmCancel({ labels, format, booking, reason, busy, onPickReason, onConfirm, onBack, className, }) {
357
- return (_jsxs("div", { className: className, children: [_jsx("p", { className: "font-medium", children: fillLabel(labels['manage.cancel.question'], {
360
+ return (_jsxs("div", { className: className, children: [_jsx("p", { className: "font-medium", children: renderLabel(labels['manage.cancel.question'], {
358
361
  appointment: theirAppointment(labels, booking.bookedForName),
359
362
  dateTime: format.clock.dateTime(booking.startTs),
360
363
  hours: booking.cancelWindowHours,
@@ -2,8 +2,8 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId, useState } from 'react';
4
4
  import { Field } from '../../primitives/field.js';
5
+ import { renderLabel } from '../internal/label-parts.js';
5
6
  import { BookingButton } from '../internal/ui.js';
6
- import { fillLabel } from '../labels.js';
7
7
  import { slotClass } from '../slots.js';
8
8
  export const AGE_CONFIRM_CARD_LABEL_KEYS = [
9
9
  'ageConfirm.heading',
@@ -35,8 +35,8 @@ export function AgeConfirmCard({ labels, name, birthYear, years, months, busy, e
35
35
  const [month, setMonth] = useState('');
36
36
  const headingId = `${id}-heading`;
37
37
  const errorId = `${id}-error`;
38
- return (_jsxs("section", { "aria-labelledby": headingId, "aria-busy": busy || undefined, className: slotClass(classNames, 'root', 'space-y-3 rounded-lg border border-secondary/40 bg-secondary/10 px-5 py-4'), children: [_jsxs("div", { className: "space-y-1", children: [_jsx("h3", { id: headingId, className: slotClass(classNames, 'heading', 'font-sans text-lg font-bold'), children: fillLabel(labels['ageConfirm.heading'], { name }) }), _jsx("p", { className: "text-sm text-muted-foreground", children: labels['ageConfirm.lead'] })] }), _jsxs("form", { onSubmit: (event) => {
38
+ return (_jsxs("section", { "aria-labelledby": headingId, "aria-busy": busy || undefined, className: slotClass(classNames, 'root', 'space-y-3 rounded-lg border border-secondary/40 bg-secondary/10 px-5 py-4'), children: [_jsxs("div", { className: "space-y-1", children: [_jsx("h3", { id: headingId, className: slotClass(classNames, 'heading', 'font-sans text-lg font-bold'), children: renderLabel(labels['ageConfirm.heading'], { name }) }), _jsx("p", { className: "text-sm text-muted-foreground", children: labels['ageConfirm.lead'] })] }), _jsxs("form", { onSubmit: (event) => {
39
39
  event.preventDefault();
40
40
  onConfirm({ birthYear: year, birthMonth: month });
41
- }, "aria-describedby": error ? errorId : undefined, className: slotClass(classNames, 'form', 'space-y-3'), children: [_jsxs("fieldset", { disabled: busy, className: "grid min-w-0 grid-cols-2 gap-3", children: [_jsx("legend", { className: "sr-only", children: fillLabel(labels['ageConfirm.legend'], { name }) }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-year`, children: labels['ageConfirm.birthYear'] }), _jsx("select", { id: `${id}-year`, value: year, onChange: (event) => setYear(event.target.value), className: SELECT_CLASS, children: years.map((option) => (_jsx("option", { value: String(option), children: option }, option))) })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-month`, children: labels['ageConfirm.birthMonth'] }), _jsxs("select", { id: `${id}-month`, value: month, onChange: (event) => setMonth(event.target.value), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['ageConfirm.birthMonthUnknown'] }), months.map((label, index) => (_jsx("option", { value: String(index + 1), children: label }, label)))] })] })] }), _jsxs("div", { className: slotClass(classNames, 'actions', 'flex flex-wrap items-center gap-3'), children: [_jsx(BookingButton, { type: "submit", size: "sm", disabled: busy, children: labels['ageConfirm.confirm'] }), _jsx(BookingButton, { type: "button", variant: "ghost", size: "sm", disabled: busy, onClick: onDismiss, children: labels['ageConfirm.dismiss'] }), _jsx("span", { "aria-live": "polite", className: "min-h-5 text-sm", children: error && _jsx(Field.Error, { id: errorId, children: error }) })] })] })] }));
41
+ }, "aria-describedby": error ? errorId : undefined, className: slotClass(classNames, 'form', 'space-y-3'), children: [_jsxs("fieldset", { disabled: busy, className: "grid min-w-0 grid-cols-2 gap-3", children: [_jsx("legend", { className: "sr-only", children: renderLabel(labels['ageConfirm.legend'], { name }) }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-year`, children: labels['ageConfirm.birthYear'] }), _jsx("select", { id: `${id}-year`, value: year, onChange: (event) => setYear(event.target.value), className: SELECT_CLASS, children: years.map((option) => (_jsx("option", { value: String(option), children: option }, option))) })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-month`, children: labels['ageConfirm.birthMonth'] }), _jsxs("select", { id: `${id}-month`, value: month, onChange: (event) => setMonth(event.target.value), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['ageConfirm.birthMonthUnknown'] }), months.map((label, index) => (_jsx("option", { value: String(index + 1), children: label }, label)))] })] })] }), _jsxs("div", { className: slotClass(classNames, 'actions', 'flex flex-wrap items-center gap-3'), children: [_jsx(BookingButton, { type: "submit", size: "sm", disabled: busy, children: labels['ageConfirm.confirm'] }), _jsx(BookingButton, { type: "button", variant: "ghost", size: "sm", disabled: busy, onClick: onDismiss, children: labels['ageConfirm.dismiss'] }), _jsx("span", { "aria-live": "polite", className: "min-h-5 text-sm", children: error && _jsx(Field.Error, { id: errorId, children: error }) })] })] })] }));
42
42
  }
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { ArrowRight } from 'lucide-react';
4
4
  import { cn } from '../../lib/utils.js';
5
- import { fillLabel } from '../labels.js';
5
+ import { renderLabel } from '../internal/label-parts.js';
6
6
  import { slotClass } from '../slots.js';
7
7
  export const CHILD_CARDS_LABEL_KEYS = [
8
8
  'childCards.empty',
@@ -20,11 +20,11 @@ const AVATAR_TINTS = ['bg-secondary/30', 'bg-primary/10', 'bg-accent'];
20
20
  function lastVisitLine(child, labels, format) {
21
21
  const age = format.ageLabel(child.ageRange);
22
22
  if (child.lastVisitTs === null)
23
- return fillLabel(labels['childCards.neverVisited'], { age });
23
+ return renderLabel(labels['childCards.neverVisited'], { age });
24
24
  const date = format.clock.date(child.lastVisitTs);
25
25
  return child.serviceName
26
- ? fillLabel(labels['childCards.lastVisit'], { age, service: child.serviceName, date })
27
- : fillLabel(labels['childCards.lastVisitNoService'], { age, date });
26
+ ? renderLabel(labels['childCards.lastVisit'], { age, service: child.serviceName, date })
27
+ : renderLabel(labels['childCards.lastVisitNoService'], { age, date });
28
28
  }
29
29
  /**
30
30
  * «My children» — one card per child, and one tap to book them again. A plain
@@ -44,7 +44,7 @@ export function ChildCards({ kids, variant = 'full', hrefFor, onBook, empty, sty
44
44
  export function DefaultChildCard({ child, index, variant, href, onBook, stylistNames, labels, format, classNames, }) {
45
45
  return (_jsxs("li", { className: slotClass(classNames, 'card', 'flex gap-4 rounded-lg border border-border bg-card px-5 py-4', variant === 'full' ? 'flex-col' : 'items-center'), children: [_jsxs("div", { className: "flex min-w-0 items-center gap-3", children: [_jsx("span", { "aria-hidden": "true", className: slotClass(classNames, 'avatar', 'flex size-11 shrink-0 items-center justify-center rounded-full font-sans text-lg font-bold', AVATAR_TINTS[index % AVATAR_TINTS.length]), children: child.name.trim().charAt(0).toUpperCase() }), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: slotClass(classNames, 'name', 'block truncate font-semibold'), children: child.name }), _jsx("span", { className: slotClass(classNames, 'detail', 'block truncate text-sm text-muted-foreground'), children: lastVisitLine(child, labels, format) })] })] }), variant === 'full' && (_jsx(ChildDetails, { child: child, stylistNames: stylistNames, labels: labels, format: format, className: classNames?.details })), _jsxs("a", { href: href, onClick: onBook ? () => onBook(child) : undefined, className: slotClass(classNames, 'link', 'font-semibold text-primary underline underline-offset-4', variant === 'full'
46
46
  ? 'inline-flex items-center gap-2 no-underline'
47
- : 'ms-auto shrink-0 text-sm'), children: [fillLabel(labels['childCards.book'], { name: child.name }), variant === 'full' && _jsx(ArrowRight, { "aria-hidden": "true", className: "size-4" })] })] }));
47
+ : 'ms-auto shrink-0 text-sm'), children: [renderLabel(labels['childCards.book'], { name: child.name }), variant === 'full' && _jsx(ArrowRight, { "aria-hidden": "true", className: "size-4" })] })] }));
48
48
  }
49
49
  /**
50
50
  * The full card's two facts beyond the last visit: the usual stylist and the
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId, useState, useTransition } from 'react';
4
4
  import { Field } from '../../primitives/field.js';
5
5
  import { Input } from '../../primitives/input.js';
6
+ import { renderLabel } from '../internal/label-parts.js';
6
7
  import { BOOKING_INPUT_CLASS, BookingButton } from '../internal/ui.js';
7
8
  import { fillLabel } from '../labels.js';
8
9
  import { slotClass } from '../slots.js';
@@ -92,7 +93,7 @@ export function DataControls({ labels, onExport, onDelete, onSessionExpired, cla
92
93
  setError(message);
93
94
  });
94
95
  }
95
- return (_jsxs("section", { "aria-labelledby": "portal-data-heading", className: slotClass(classNames, 'root', 'space-y-4'), children: [_jsx("h2", { id: "portal-data-heading", className: slotClass(classNames, 'heading', 'font-sans text-xl font-bold md:text-2xl'), children: labels['dataControls.heading'] }), _jsxs("div", { className: slotClass(classNames, 'actions', 'flex flex-wrap items-center gap-3'), children: [_jsx(BookingButton, { variant: "outline", onClick: exportData, disabled: pending, children: labels['dataControls.export'] }), !confirming && (_jsx(BookingButton, { variant: "destructive", onClick: () => setConfirming(true), disabled: pending, children: labels['dataControls.delete'] }))] }), _jsx("div", { "aria-live": "polite", children: exportError && _jsx(Field.Error, { children: exportError }) }), confirming && (_jsxs("form", { onSubmit: deleteMe, "aria-labelledby": `${id}-delete-heading`, className: slotClass(classNames, 'confirm', 'space-y-4 rounded-lg border border-destructive/40 bg-destructive/10 px-5 py-4'), children: [_jsx("p", { id: `${id}-delete-heading`, className: "font-semibold", children: labels['dataControls.confirmHeading'] }), _jsx("p", { className: "text-sm", children: labels['dataControls.consequences'] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: confirmId, children: fillLabel(labels['dataControls.confirmLabel'], { word }) }), _jsx(Input, { id: confirmId, autoComplete: "off", autoCapitalize: "characters", value: typed, "aria-describedby": `${confirmId}-help${error ? ` ${errorId}` : ''}`, "aria-invalid": error ? true : undefined, onChange: (event) => {
96
+ return (_jsxs("section", { "aria-labelledby": "portal-data-heading", className: slotClass(classNames, 'root', 'space-y-4'), children: [_jsx("h2", { id: "portal-data-heading", className: slotClass(classNames, 'heading', 'font-sans text-xl font-bold md:text-2xl'), children: labels['dataControls.heading'] }), _jsxs("div", { className: slotClass(classNames, 'actions', 'flex flex-wrap items-center gap-3'), children: [_jsx(BookingButton, { variant: "outline", onClick: exportData, disabled: pending, children: labels['dataControls.export'] }), !confirming && (_jsx(BookingButton, { variant: "destructive", onClick: () => setConfirming(true), disabled: pending, children: labels['dataControls.delete'] }))] }), _jsx("div", { "aria-live": "polite", children: exportError && _jsx(Field.Error, { children: exportError }) }), confirming && (_jsxs("form", { onSubmit: deleteMe, "aria-labelledby": `${id}-delete-heading`, className: slotClass(classNames, 'confirm', 'space-y-4 rounded-lg border border-destructive/40 bg-destructive/10 px-5 py-4'), children: [_jsx("p", { id: `${id}-delete-heading`, className: "font-semibold", children: labels['dataControls.confirmHeading'] }), _jsx("p", { className: "text-sm", children: labels['dataControls.consequences'] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: confirmId, children: renderLabel(labels['dataControls.confirmLabel'], { word }) }), _jsx(Input, { id: confirmId, autoComplete: "off", autoCapitalize: "characters", value: typed, "aria-describedby": `${confirmId}-help${error ? ` ${errorId}` : ''}`, "aria-invalid": error ? true : undefined, onChange: (event) => {
96
97
  setTyped(event.target.value);
97
98
  setError(null);
98
99
  }, className: BOOKING_INPUT_CLASS }), _jsx(Field.Description, { id: `${confirmId}-help`, children: labels['dataControls.confirmHelp'] })] }), _jsx("div", { "aria-live": "polite", children: error && _jsx(Field.Error, { id: errorId, children: error }) }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx(BookingButton, { type: "submit", variant: "destructive", disabled: pending, children: labels['dataControls.deleteForever'] }), _jsx(BookingButton, { variant: "ghost", disabled: pending, onClick: () => {
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { fillLabel } from '../labels.js';
2
+ import { renderLabel } from '../internal/label-parts.js';
3
3
  import { slotClass } from '../slots.js';
4
4
  export const PORTAL_UNREACHABLE_LABEL_KEYS = [
5
5
  'portalUnreachable.heading',
@@ -15,5 +15,5 @@ export const PORTAL_UNREACHABLE_LABEL_KEYS = [
15
15
  */
16
16
  export function PortalUnreachable({ labels, format, phone, retryHref, logout, headingId = 'portal-unreachable-heading', classNames, }) {
17
17
  const link = slotClass(classNames, 'link', 'font-semibold text-primary underline underline-offset-4');
18
- return (_jsxs("section", { "aria-labelledby": headingId, className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h1", { id: headingId, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold md:text-3xl'), children: labels['portalUnreachable.heading'] }), _jsx("p", { className: "text-muted-foreground", children: labels['portalUnreachable.body'] }), _jsxs("div", { className: slotClass(classNames, 'actions', 'flex flex-wrap items-center gap-4'), children: [_jsx("a", { href: retryHref, className: link, children: labels['portalUnreachable.retry'] }), phone && (_jsx("a", { href: format.telHref(phone), className: link, children: fillLabel(labels['portalUnreachable.call'], { phone }) })), logout] })] }));
18
+ return (_jsxs("section", { "aria-labelledby": headingId, className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h1", { id: headingId, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold md:text-3xl'), children: labels['portalUnreachable.heading'] }), _jsx("p", { className: "text-muted-foreground", children: labels['portalUnreachable.body'] }), _jsxs("div", { className: slotClass(classNames, 'actions', 'flex flex-wrap items-center gap-4'), children: [_jsx("a", { href: retryHref, className: link, children: labels['portalUnreachable.retry'] }), phone && (_jsx("a", { href: format.telHref(phone), className: link, children: renderLabel(labels['portalUnreachable.call'], { phone }) })), logout] })] }));
19
19
  }
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { cn } from '../../lib/utils.js';
4
- import { labelParts } from '../internal/label-parts.js';
4
+ import { joinLabelParts, labelParts, renderLabel } from '../internal/label-parts.js';
5
5
  import { bookingButtonClass } from '../internal/ui.js';
6
6
  import { fillLabel } from '../labels.js';
7
7
  import { slotClass } from '../slots.js';
@@ -36,22 +36,18 @@ function howFarOff(startTs, now, labels, format) {
36
36
  }
37
37
  /** What is booked and who for. */
38
38
  function whatLine(booking, labels) {
39
- return [booking.serviceName ?? labels['upcoming.serviceFallback'], booking.bookedForName]
40
- .filter(Boolean)
41
- .join(SEPARATOR);
39
+ return joinLabelParts([booking.serviceName ?? labels['upcoming.serviceFallback'], booking.bookedForName], SEPARATOR);
42
40
  }
43
41
  /** When, with whom, and what it costs. */
44
42
  function whenLine(booking, now, labels, format) {
45
- return [
46
- fillLabel(labels['upcoming.when'], {
43
+ return joinLabelParts([
44
+ renderLabel(labels['upcoming.when'], {
47
45
  day: format.clock.dayLabel(booking.startTs, now),
48
46
  time: format.clock.formatTime(booking.startTs),
49
47
  }),
50
48
  booking.resourceName ?? labels['upcoming.anyStylist'],
51
49
  booking.amountOre === null ? null : format.price(booking.amountOre),
52
- ]
53
- .filter(Boolean)
54
- .join(SEPARATOR);
50
+ ], SEPARATOR);
55
51
  }
56
52
  /**
57
53
  * «Upcoming appointments» — one card per appointment still ahead.
@@ -72,7 +68,7 @@ export function UpcomingBookings({ bookings, phone, labels, format, bookingHref,
72
68
  export function DefaultBookingCard({ booking, phone, now, hero, labels, format, classNames, }) {
73
69
  return (_jsxs("li", { className: cn('space-y-2 px-5 py-4', hero
74
70
  ? slotClass(classNames, 'cardHero', 'rounded-2xl bg-primary p-6 text-primary-foreground md:p-8')
75
- : slotClass(classNames, 'card', 'rounded-lg border border-border bg-card')), children: [hero && (_jsxs("p", { className: slotClass(classNames, 'badge', 'inline-flex items-center gap-2 rounded-full bg-primary-foreground/15 px-3 py-1 text-xs font-bold'), children: [_jsx("span", { "aria-hidden": "true", className: "size-2 rounded-full bg-current" }), fillLabel(labels['upcoming.next'], {
71
+ : slotClass(classNames, 'card', 'rounded-lg border border-border bg-card')), children: [hero && (_jsxs("p", { className: slotClass(classNames, 'badge', 'inline-flex items-center gap-2 rounded-full bg-primary-foreground/15 px-3 py-1 text-xs font-bold'), children: [_jsx("span", { "aria-hidden": "true", className: "size-2 rounded-full bg-current" }), renderLabel(labels['upcoming.next'], {
76
72
  when: howFarOff(booking.startTs, now, labels, format),
77
73
  })] })), _jsx("p", { className: cn('font-semibold', hero && 'font-sans text-2xl md:text-3xl'), children: whatLine(booking, labels) }), _jsx("p", { className: cn('tabular-nums', hero && slotClass(classNames, 'heroMuted', 'text-primary-foreground/85')), children: whenLine(booking, now, labels, format) }), booking.managePath ? (_jsx("a", { href: booking.managePath, className: slotClass(classNames, 'manage', 'text-sm font-semibold underline underline-offset-4', hero
78
74
  ? 'inline-block rounded-lg bg-background px-4 py-2 text-foreground no-underline'
@@ -80,7 +76,7 @@ export function DefaultBookingCard({ booking, phone, now, hero, labels, format,
80
76
  ? slotClass(classNames, 'heroMuted', 'text-primary-foreground/85')
81
77
  : 'text-muted-foreground'), children: phone
82
78
  ? labelParts(labels['upcoming.noManageWithPhone'], {
83
- call: (_jsx("a", { href: format.telHref(phone), className: cn('font-semibold underline underline-offset-4', hero ? 'text-primary-foreground' : 'text-primary'), children: fillLabel(labels['upcoming.call'], { phone }) })),
79
+ call: (_jsx("a", { href: format.telHref(phone), className: cn('font-semibold underline underline-offset-4', hero ? 'text-primary-foreground' : 'text-primary'), children: renderLabel(labels['upcoming.call'], { phone }) })),
84
80
  })
85
81
  : // No number on file: the sentence without a link, because a `tel:`
86
82
  // that dials nothing fails in the hand of someone already stuck.
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useState } from 'react';
4
4
  import { cn } from '../../lib/utils.js';
5
- import { fillLabel } from '../labels.js';
5
+ import { renderLabel } from '../internal/label-parts.js';
6
6
  import { slotClass } from '../slots.js';
7
7
  export const VISIT_HISTORY_LABEL_KEYS = [
8
8
  'history.heading',
@@ -60,7 +60,7 @@ export function VisitHistory({ past, labels, format, years: yearsProp, headingId
60
60
  return (_jsx("button", { type: "button", "aria-pressed": pressed, onClick: () => setSelected(year.year), className: cn(slotClass(classNames, 'yearChip', 'rounded-full border px-4 py-1.5 text-sm font-semibold transition-colors', pressed
61
61
  ? 'border-foreground bg-foreground text-background'
62
62
  : 'border-border hover:bg-muted'), pressed && classNames?.yearChipSelected), children: year.year }, year.year));
63
- }) })), _jsx("ul", { className: slotClass(classNames, 'list', 'divide-y divide-border border-y border-border'), children: shown.visits.map((visit) => (_jsx(Row, { visit: visit, labels: labels, format: format, classNames: classNames }, visit.bookingId))) }), _jsxs("p", { className: slotClass(classNames, 'total', 'flex flex-wrap items-baseline justify-between gap-3 rounded-lg bg-muted px-5 py-4'), children: [_jsx("span", { className: "font-semibold", children: fillLabel(labels[shown.visits.length === 1 ? 'history.count.one' : 'history.count.other'], { count: shown.visits.length, year: shown.year }) }), _jsx("span", { className: "font-sans text-lg font-bold tabular-nums", children: shown.totalOre === null
63
+ }) })), _jsx("ul", { className: slotClass(classNames, 'list', 'divide-y divide-border border-y border-border'), children: shown.visits.map((visit) => (_jsx(Row, { visit: visit, labels: labels, format: format, classNames: classNames }, visit.bookingId))) }), _jsxs("p", { className: slotClass(classNames, 'total', 'flex flex-wrap items-baseline justify-between gap-3 rounded-lg bg-muted px-5 py-4'), children: [_jsx("span", { className: "font-semibold", children: renderLabel(labels[shown.visits.length === 1 ? 'history.count.one' : 'history.count.other'], { count: shown.visits.length, year: shown.year }) }), _jsx("span", { className: "font-sans text-lg font-bold tabular-nums", children: shown.totalOre === null
64
64
  ? labels['history.unknownAmount']
65
65
  : format.price(shown.totalOre) })] })] }))] }));
66
66
  }
@@ -17,8 +17,9 @@ export type ServiceScreenLabels = Record<(typeof SERVICE_SCREEN_LABEL_KEYS)[numb
17
17
  * - `pill` / `pillSelected` — a category pill / the marked one
18
18
  * - `groupHeading` — a category's (or a party member's) `<h3>`
19
19
  * - `card` / `cardSelected` — a service card / a pressed one (passed to `ServiceCard`)
20
+ * - `price` — a card's price (passed to `ServiceCard`, and the «same as last time» card's)
20
21
  */
21
- export type ServiceScreenSlot = 'root' | 'heading' | 'pill' | 'pillSelected' | 'groupHeading' | 'card' | 'cardSelected';
22
+ export type ServiceScreenSlot = 'root' | 'heading' | 'pill' | 'pillSelected' | 'groupHeading' | 'card' | 'cardSelected' | 'price';
22
23
  /** One service group, in display order. */
23
24
  export interface ServiceScreenCategory {
24
25
  key: string;
@@ -56,6 +57,8 @@ export interface ServiceCardProps {
56
57
  labels: ServiceScreenLabels;
57
58
  className?: string;
58
59
  selectedClassName?: string;
60
+ /** Merged onto the price (the `price` slot). */
61
+ priceClassName?: string;
59
62
  }
60
63
  export interface ServiceScreenComponents {
61
64
  ServiceCard?: ComponentType<ServiceCardProps>;
@@ -134,4 +137,4 @@ export declare function ServiceScreen(props: ServiceScreenProps): import("react"
134
137
  * online, and a tap target that accepts the tap and does nothing is the dead
135
138
  * end the phone number replaces. The card stays so the catalogue is whole.
136
139
  */
137
- export declare function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, }: ServiceCardProps): import("react").JSX.Element;
140
+ export declare function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, priceClassName, }: ServiceCardProps): import("react").JSX.Element;
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useState } from 'react';
4
4
  import { cn } from '../lib/utils.js';
5
+ import { renderLabel } from './internal/label-parts.js';
5
6
  import { BookingButton } from './internal/ui.js';
6
7
  import { fillLabel } from './labels.js';
7
8
  import { slotClass } from './slots.js';
@@ -57,6 +58,7 @@ function cardProps(shared) {
57
58
  labels: shared.labels,
58
59
  className: shared.classNames?.card,
59
60
  selectedClassName: shared.classNames?.cardSelected,
61
+ priceClassName: shared.classNames?.price,
60
62
  };
61
63
  }
62
64
  export function ServiceScreen(props) {
@@ -187,7 +189,7 @@ function PartyServiceScreen({ services, categories, categoryOf, childCategory, s
187
189
  */
188
190
  function NothingInThisParty({ person, labels, onRemove, }) {
189
191
  const values = { label: person.label };
190
- return (_jsxs("div", { className: "space-y-2 rounded-lg border border-dashed border-border px-5 py-4 text-sm", children: [_jsx("p", { children: fillLabel(labels[person.adult ? 'service.party.adultAlone' : 'service.party.nothingFits'], values) }), onRemove && (_jsx("button", { type: "button", onClick: onRemove, className: "font-semibold text-primary underline underline-offset-4", children: fillLabel(labels['service.party.remove'], values) }))] }));
192
+ return (_jsxs("div", { className: "space-y-2 rounded-lg border border-dashed border-border px-5 py-4 text-sm", children: [_jsx("p", { children: renderLabel(labels[person.adult ? 'service.party.adultAlone' : 'service.party.nothingFits'], values) }), onRemove && (_jsx("button", { type: "button", onClick: onRemove, className: "font-semibold text-primary underline underline-offset-4", children: renderLabel(labels['service.party.remove'], values) }))] }));
191
193
  }
192
194
  /**
193
195
  * One person with a «same as last time»: the suggestion as the answer, and the
@@ -206,7 +208,7 @@ function SameAsLast({ suggestion, selected, onPick, shared, }) {
206
208
  const { labels, format, classNames } = shared;
207
209
  return (_jsxs("div", { className: "space-y-1", children: [_jsxs("button", { type: "button", "aria-pressed": selected, onClick: onPick, className: cn('flex min-h-14 w-full items-baseline justify-between gap-4 rounded-lg border px-5 py-4 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', selected
208
210
  ? 'border-primary bg-primary/5 ring-2 ring-primary'
209
- : 'border-primary/60 bg-card hover:border-primary', classNames?.card, selected && classNames?.cardSelected), children: [_jsxs("span", { children: [_jsx("span", { className: "block text-xs font-bold tracking-wide text-primary uppercase", children: labels['service.sameAsLast'] }), _jsx("span", { className: "font-medium", children: service.name })] }), _jsxs("span", { className: "flex items-baseline gap-4 text-right", children: [_jsx("span", { className: "text-sm text-muted-foreground tabular-nums", children: fillLabel(labels['service.duration'], { minutes: service.durationMinutes }) }), _jsx("span", { className: "whitespace-nowrap font-semibold text-foreground tabular-nums", children: format.price(service.priceOre) })] })] }), note && _jsx("p", { className: "text-sm text-muted-foreground", children: note })] }));
211
+ : 'border-primary/60 bg-card hover:border-primary', classNames?.card, selected && classNames?.cardSelected), children: [_jsxs("span", { children: [_jsx("span", { className: "block text-xs font-bold tracking-wide text-primary uppercase", children: labels['service.sameAsLast'] }), _jsx("span", { className: "font-medium", children: service.name })] }), _jsxs("span", { className: "flex items-baseline gap-4 text-right", children: [_jsx("span", { className: "text-sm text-muted-foreground tabular-nums", children: renderLabel(labels['service.duration'], { minutes: service.durationMinutes }) }), _jsx("span", { className: slotClass(classNames, 'price', 'whitespace-nowrap font-semibold text-foreground tabular-nums'), children: format.price(service.priceOre) })] })] }), note && _jsx("p", { className: "text-sm text-muted-foreground", children: note })] }));
210
212
  }
211
213
  /**
212
214
  * The default service card.
@@ -218,9 +220,9 @@ function SameAsLast({ suggestion, selected, onPick, shared, }) {
218
220
  * online, and a tap target that accepts the tap and does nothing is the dead
219
221
  * end the phone number replaces. The card stays so the catalogue is whole.
220
222
  */
221
- export function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, }) {
222
- const duration = fillLabel(labels['service.duration'], { minutes: service.durationMinutes });
223
- const price = (_jsx("span", { className: "whitespace-nowrap font-semibold text-foreground tabular-nums", children: format.price(service.priceOre) }));
223
+ export function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, priceClassName, }) {
224
+ const duration = renderLabel(labels['service.duration'], { minutes: service.durationMinutes });
225
+ const price = (_jsx("span", { className: cn('whitespace-nowrap font-semibold text-foreground tabular-nums', priceClassName), children: format.price(service.priceOre) }));
224
226
  if (kind === 'phone') {
225
227
  return (_jsxs("div", { className: cn('flex min-h-14 w-full items-baseline justify-between gap-4 rounded-lg border border-dashed border-border px-5 py-4', className), children: [_jsx("span", { className: "font-medium", children: service.name }), _jsxs("span", { className: "flex items-baseline gap-4 text-right", children: [_jsx("span", { className: "text-sm text-muted-foreground tabular-nums", children: duration }), _jsxs("span", { className: "text-sm", children: [labels['service.phoneOnly'], phone ? (_jsxs(_Fragment, { children: [' – ', _jsx("a", { href: format.telHref(phone), "aria-label": fillLabel(labels['service.phoneOnlyLinkLabel'], { phone }), className: "font-semibold text-primary underline underline-offset-4", children: phone })] })) : null] })] })] }));
226
228
  }
@@ -27,8 +27,9 @@ export type StylistScreenLabels = Record<(typeof STYLIST_SCREEN_LABEL_KEYS)[numb
27
27
  * - `group` — the radiogroup (the sideways row / the column)
28
28
  * - `card` / `cardSelected` — a stylist option / the checked one (passed to `StylistCard`)
29
29
  * - `modeCard` / `modeCardSelected` — a family's mode card / the pressed one
30
+ * - `nextAvailable` — a stylist option's next-opening line (passed to `StylistCard`)
30
31
  */
31
- export type StylistScreenSlot = 'root' | 'heading' | 'group' | 'card' | 'cardSelected' | 'modeCard' | 'modeCardSelected';
32
+ export type StylistScreenSlot = 'root' | 'heading' | 'group' | 'card' | 'cardSelected' | 'modeCard' | 'modeCardSelected' | 'nextAvailable';
32
33
  /** One option of the radiogroup as the card draws it. */
33
34
  export interface StylistOption {
34
35
  /** `null` is «first available». */
@@ -55,6 +56,8 @@ export interface StylistCardProps {
55
56
  labels: StylistScreenLabels;
56
57
  className?: string;
57
58
  selectedClassName?: string;
59
+ /** Merged onto the next-opening line (the `nextAvailable` slot). */
60
+ nextAvailableClassName?: string;
58
61
  }
59
62
  export interface StylistScreenComponents {
60
63
  StylistCard?: ComponentType<StylistCardProps>;
@@ -102,6 +105,14 @@ export interface StylistScreenProps {
102
105
  mode: PartyMode;
103
106
  /** How many people, for the option labels. */
104
107
  size: number;
108
+ /**
109
+ * The size as the copy spells it (e.g. «two»), filled into `{sizeWord}`
110
+ * in `stylist.party.parallel.*` and `stylist.party.parallelNote.*`.
111
+ * Default: the number. Lets a pack keep the word as its own text piece
112
+ * (`'We find {sizeWord} stylists …'`) rather than writing it into the
113
+ * sentence.
114
+ */
115
+ sizeWord?: string;
105
116
  /** How long the visit is in each mode. */
106
117
  minutes: {
107
118
  sequential: number;
@@ -114,4 +125,4 @@ export interface StylistScreenProps {
114
125
  }
115
126
  export declare function StylistScreen({ labels, format, now, serviceIds, resources, selectedResourceId, nextAvailableTs, nextAvailableLoading, loading, skeletonCount, pendingName, notice, onPick, party, classNames, components, }: StylistScreenProps): import("react").JSX.Element;
116
127
  /** The default stylist option: avatar (photo or badge), name(s), bio, next opening. */
117
- export declare function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, labels, className, selectedClassName, }: StylistCardProps): import("react").JSX.Element;
128
+ export declare function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, labels, className, selectedClassName, nextAvailableClassName, }: StylistCardProps): import("react").JSX.Element;
@@ -1,8 +1,8 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useId, useRef } from 'react';
3
+ import { useEffect, useId, useRef, } from 'react';
4
4
  import { cn } from '../lib/utils.js';
5
- import { fillLabel } from './labels.js';
5
+ import { renderLabel } from './internal/label-parts.js';
6
6
  import { LiveStatus } from './live-status.js';
7
7
  import { slotClass } from './slots.js';
8
8
  /**
@@ -55,10 +55,13 @@ function firstName(displayName) {
55
55
  }
56
56
  /** Sizes the label packs write out in words; any other size reads `.other`. */
57
57
  const SIZE_WORDS = { 2: 'two', 3: 'three' };
58
- /** The size-specific label: `.two`, `.three`, else `.other` with `{count}`. */
59
- function bySize(labels, base, size) {
58
+ /**
59
+ * The size-specific label: `.two`, `.three`, else `.other`, filled with
60
+ * `{count}` (the number) and `{sizeWord}` (`party.sizeWord`, else the number).
61
+ */
62
+ function bySize(labels, base, size, sizeWord) {
60
63
  const suffix = SIZE_WORDS[size] ?? 'other';
61
- return fillLabel(labels[`${base}.${suffix}`], { count: size });
64
+ return renderLabel(labels[`${base}.${suffix}`], { count: size, sizeWord: sizeWord ?? size });
62
65
  }
63
66
  export function StylistScreen({ labels, format, now, serviceIds, resources, selectedResourceId = null, nextAvailableTs, nextAvailableLoading = false, loading = false, skeletonCount = DEFAULT_SKELETON_COUNT, pendingName = null, notice = null, onPick, party, classNames, components, }) {
64
67
  // `every`, not `some`: the step asks one question for the whole visit, so a
@@ -103,11 +106,11 @@ export function StylistScreen({ labels, format, now, serviceIds, resources, sele
103
106
  };
104
107
  })),
105
108
  ];
106
- return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h2", { id: HEADING_ID, tabIndex: -1, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: labels['stylist.heading'] }), party && (_jsxs("fieldset", { className: "flex flex-col gap-2 sm:flex-row", children: [_jsx("legend", { className: "mb-2 font-sans text-lg font-bold", children: labels['stylist.party.legend'] }), _jsx(ModeCard, { selected: party.mode === 'sequential', onClick: () => party.onMode('sequential'), title: labels['stylist.party.sequential'], subtitle: fillLabel(labels['stylist.party.sequentialMinutes'], {
109
+ return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h2", { id: HEADING_ID, tabIndex: -1, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: labels['stylist.heading'] }), party && (_jsxs("fieldset", { className: "flex flex-col gap-2 sm:flex-row", children: [_jsx("legend", { className: "mb-2 font-sans text-lg font-bold", children: labels['stylist.party.legend'] }), _jsx(ModeCard, { selected: party.mode === 'sequential', onClick: () => party.onMode('sequential'), title: labels['stylist.party.sequential'], subtitle: renderLabel(labels['stylist.party.sequentialMinutes'], {
107
110
  minutes: party.minutes.sequential,
108
- }), classNames: classNames }), _jsx(ModeCard, { selected: party.mode === 'parallel', onClick: () => party.onMode('parallel'), title: bySize(labels, 'stylist.party.parallel', party.size), subtitle: fillLabel(labels['stylist.party.parallelMinutes'], {
111
+ }), classNames: classNames }), _jsx(ModeCard, { selected: party.mode === 'parallel', onClick: () => party.onMode('parallel'), title: bySize(labels, 'stylist.party.parallel', party.size, party.sizeWord), subtitle: renderLabel(labels['stylist.party.parallelMinutes'], {
109
112
  minutes: party.minutes.parallel,
110
- }), classNames: classNames })] })), party?.mode === 'parallel' ? (_jsx("p", { className: "rounded-lg border border-border bg-card px-5 py-4 text-sm", children: bySize(labels, 'stylist.party.parallelNote', party.size) })) : (_jsx(StylistRadioGroup, { options: options, selectedResourceId: selectedResourceId,
113
+ }), classNames: classNames })] })), party?.mode === 'parallel' ? (_jsx("p", { className: "rounded-lg border border-border bg-card px-5 py-4 text-sm", children: bySize(labels, 'stylist.party.parallelNote', party.size, party.sizeWord) })) : (_jsx(StylistRadioGroup, { options: options, selectedResourceId: selectedResourceId,
111
114
  // The held option takes one placeholder's place, so the row is
112
115
  // exactly as long as it was and nothing after it moves.
113
116
  skeletons: loading ? Math.max(0, skeletonCount - (options.length - 1)) : 0, busy: loading, onPick: onPick, labels: labels, classNames: classNames, Card: components?.StylistCard ?? DefaultStylistCard })), _jsx(LiveStatus, { text: loading ? labels['stylist.loading'] : notice })] }));
@@ -174,7 +177,7 @@ function StylistRadioGroup({ options, selectedResourceId, skeletons, busy, onPic
174
177
  // box would otherwise clip. `md`: the column of cards.
175
178
  className: slotClass(classNames, 'group', 'relative -mx-1 flex h-36 snap-x snap-mandatory scroll-px-1 gap-1 overflow-x-auto overscroll-x-contain px-1 py-2', 'md:mx-0 md:h-auto md:snap-none md:flex-col md:gap-2 md:overflow-visible md:p-0'), children: [options.map((option, index) => (_jsx(Card, { ref: (node) => {
176
179
  optionRefs.current[index] = node;
177
- }, option: option, selected: option.resourceId === selectedResourceId, tabIndex: index === tabStop ? 0 : -1, onClick: () => onPick(option.resourceId), labels: labels, className: classNames?.card, selectedClassName: classNames?.cardSelected }, option.resourceId ?? 'first-available'))), Array.from({ length: skeletons }, (_, index) => (_jsx(SkeletonCard, {}, index)))] }));
180
+ }, option: option, selected: option.resourceId === selectedResourceId, tabIndex: index === tabStop ? 0 : -1, onClick: () => onPick(option.resourceId), labels: labels, className: classNames?.card, selectedClassName: classNames?.cardSelected, nextAvailableClassName: classNames?.nextAvailable }, option.resourceId ?? 'first-available'))), Array.from({ length: skeletons }, (_, index) => (_jsx(SkeletonCard, {}, index)))] }));
178
181
  }
179
182
  /** The restored stylist's stand-in: a real, checked radio of the final size,
180
183
  * with its next-opening row pulsing like every card still waiting for one. */
@@ -204,7 +207,7 @@ function ModeCard({ selected, onClick, title, subtitle, classNames, }) {
204
207
  : 'border-border bg-card hover:border-primary', classNames?.modeCard, selected && classNames?.modeCardSelected), children: [_jsx("span", { className: "font-medium", children: title }), _jsx("span", { className: "text-sm font-normal text-muted-foreground tabular-nums", children: subtitle })] }));
205
208
  }
206
209
  /** The default stylist option: avatar (photo or badge), name(s), bio, next opening. */
207
- export function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, labels, className, selectedClassName, }) {
210
+ export function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, labels, className, selectedClassName, nextAvailableClassName, }) {
208
211
  const id = useId();
209
212
  const nameId = `${id}-name`;
210
213
  const nextId = `${id}-next`;
@@ -218,7 +221,7 @@ export function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, l
218
221
  ? 'font-semibold md:border-primary md:bg-primary/10 md:ring-2 md:ring-primary'
219
222
  : 'md:border-border md:bg-card md:hover:border-primary', className, selected && selectedClassName), children: [_jsx("span", { "aria-hidden": "true", "data-testid": "stylist-avatar", className: cn('flex size-14 shrink-0 items-center justify-center overflow-hidden rounded-full bg-secondary/30 font-semibold md:size-12', selected
220
223
  ? 'ring-2 ring-primary ring-offset-2 ring-offset-background md:ring-0 md:ring-offset-0'
221
- : 'ring-1 ring-border md:ring-0'), children: photoUrl ? (_jsx("img", { src: photoUrl, alt: "", className: "size-full object-cover", width: 56, height: 56, loading: "lazy", decoding: "async" })) : (badge) }), _jsxs("span", { className: "flex w-full min-w-0 flex-col md:w-auto", children: [_jsx("span", { "aria-hidden": "true", className: "h-5 truncate text-sm font-medium md:hidden", children: option.shortName }), _jsx("span", { id: nameId, className: "hidden truncate font-medium md:block", children: name }), subtitle && (_jsx("span", { id: bioId, className: "hidden truncate text-sm text-muted-foreground md:block", children: subtitle })), availability ? (_jsxs("span", { id: nextId, "data-testid": "next-available-row", className: "line-clamp-2 h-8 text-xs leading-4 text-foreground tabular-nums md:line-clamp-1 md:h-5 md:text-sm md:leading-5", children: [_jsx("span", { className: "sr-only md:not-sr-only", children: labels['stylist.nextAvailable'] }), ' ', availability] })) : (reserveAvailability && (_jsx("span", { "data-testid": "next-available-row", "aria-hidden": "true", className: "flex h-8 justify-center pt-1 md:block md:h-5 md:py-1", children: availabilityLoading && (_jsx("span", { className: "block h-3 w-16 animate-pulse rounded bg-muted md:w-36" })) })))] })] }));
224
+ : 'ring-1 ring-border md:ring-0'), children: photoUrl ? (_jsx("img", { src: photoUrl, alt: "", className: "size-full object-cover", width: 56, height: 56, loading: "lazy", decoding: "async" })) : (badge) }), _jsxs("span", { className: "flex w-full min-w-0 flex-col md:w-auto", children: [_jsx("span", { "aria-hidden": "true", className: "h-5 truncate text-sm font-medium md:hidden", children: option.shortName }), _jsx("span", { id: nameId, className: "hidden truncate font-medium md:block", children: name }), subtitle && (_jsx("span", { id: bioId, className: "hidden truncate text-sm text-muted-foreground md:block", children: subtitle })), availability ? (_jsxs("span", { id: nextId, "data-testid": "next-available-row", className: cn('line-clamp-2 h-8 text-xs leading-4 text-foreground tabular-nums md:line-clamp-1 md:h-5 md:text-sm md:leading-5', nextAvailableClassName), children: [_jsx("span", { className: "sr-only md:not-sr-only", children: labels['stylist.nextAvailable'] }), ' ', availability] })) : (reserveAvailability && (_jsx("span", { "data-testid": "next-available-row", "aria-hidden": "true", className: "flex h-8 justify-center pt-1 md:block md:h-5 md:py-1", children: availabilityLoading && (_jsx("span", { className: "block h-3 w-16 animate-pulse rounded bg-muted md:w-36" })) })))] })] }));
222
225
  }
223
226
  /** A stylist option before there is a stylist: the same box, the same circle, at both widths. */
224
227
  function SkeletonCard() {
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, X } from 'lucide-react';
4
4
  import { useEffect, useState } from 'react';
5
5
  import { cn } from '../lib/utils.js';
6
+ import { renderLabel } from './internal/label-parts.js';
6
7
  import { BookingButton, bookingButtonClass } from './internal/ui.js';
7
8
  import { fillLabel } from './labels.js';
8
9
  import { LiveStatus } from './live-status.js';
@@ -155,8 +156,8 @@ function offeredSlots(slots, party) {
155
156
  function weekendSentence(labels, format, note) {
156
157
  const price = format.price(note.priceOre);
157
158
  return note.pct === null
158
- ? fillLabel(labels['time.weekend.mixed'], { price })
159
- : fillLabel(labels['time.weekend.pct'], { pct: note.pct, price });
159
+ ? renderLabel(labels['time.weekend.mixed'], { price })
160
+ : renderLabel(labels['time.weekend.pct'], { pct: note.pct, price });
160
161
  }
161
162
  // Month grid — pure calendar arithmetic over the business-local date, so a
162
163
  // viewer in another zone is never shown a grid built around the wrong day.
@@ -317,7 +318,7 @@ function MonthCalendar({ labels, clock, open, onToggle, monthTs, onMonth, strip,
317
318
  const { year, month, day: anchorDay } = ymd(clock, monthTs);
318
319
  const firstOfMonth = clock.dayStart(monthTs, 1 - anchorDay);
319
320
  const monthName = clock.monthName(month);
320
- const heading = fillLabel(labels['time.month.heading'], {
321
+ const heading = renderLabel(labels['time.month.heading'], {
321
322
  month: `${monthName.charAt(0).toUpperCase()}${monthName.slice(1)}`,
322
323
  year,
323
324
  });
@@ -380,7 +381,7 @@ function DaypartSections({ labels, clock, dayparts, daySlots, nearest, currentSl
380
381
  function ParallelInstead({ labels, clock, slot, day, now, size, onAccept, className, }) {
381
382
  const time = clock.formatTime(slot.startTs);
382
383
  const offer = size === 2 ? labels['time.parallel.offer.two'] : labels['time.parallel.offer.other'];
383
- return (_jsxs("div", { className: cn('space-y-3 rounded-lg border border-primary/40 bg-primary/5 px-5 py-4', className), children: [_jsx("p", { className: "text-sm", children: fillLabel(offer, { day: clock.dayLabel(day, now), time }) }), _jsx(BookingButton, { size: "lg", onClick: onAccept, className: "tabular-nums", children: fillLabel(labels['time.parallel.accept'], { time }) })] }));
384
+ return (_jsxs("div", { className: cn('space-y-3 rounded-lg border border-primary/40 bg-primary/5 px-5 py-4', className), children: [_jsx("p", { className: "text-sm", children: renderLabel(offer, { day: clock.dayLabel(day, now), time }) }), _jsx(BookingButton, { size: "lg", onClick: onAccept, className: "tabular-nums", children: renderLabel(labels['time.parallel.accept'], { time }) })] }));
384
385
  }
385
386
  /**
386
387
  * A day with nothing left, in four different situations:
@@ -395,17 +396,17 @@ function NothingFreeCard({ labels, format, day, now, phone, standing, stylistNam
395
396
  const dayLabel = format.clock.dayLabel(day, now);
396
397
  const cardClass = cn('rounded-lg border border-border bg-card px-5 py-4 text-sm', className);
397
398
  if (standing === 'closed' || standing === 'over') {
398
- return (_jsx("p", { className: cardClass, children: fillLabel(labels[standing === 'closed' ? 'time.empty.closed' : 'time.empty.over'], {
399
+ return (_jsx("p", { className: cardClass, children: renderLabel(labels[standing === 'closed' ? 'time.empty.closed' : 'time.empty.over'], {
399
400
  day: dayLabel,
400
401
  }) }));
401
402
  }
402
- let lead = fillLabel(labels['time.empty.unknown'], { day: dayLabel });
403
+ let lead = renderLabel(labels['time.empty.unknown'], { day: dayLabel });
403
404
  if (standing === 'open') {
404
405
  lead = stylistName
405
- ? fillLabel(labels['time.empty.stylist'], { stylist: stylistName, day: dayLabel })
406
- : fillLabel(labels['time.empty.full'], { day: dayLabel });
406
+ ? renderLabel(labels['time.empty.stylist'], { stylist: stylistName, day: dayLabel })
407
+ : renderLabel(labels['time.empty.full'], { day: dayLabel });
407
408
  }
408
- return (_jsxs("p", { className: cardClass, children: [lead, ' ', phone ? (_jsxs(_Fragment, { children: [_jsx("a", { href: format.telHref(phone), "aria-label": fillLabel(labels['time.empty.callLinkAria'], { phone }), className: "font-semibold text-primary underline underline-offset-4", children: fillLabel(labels['time.empty.callLink'], { phone }) }), labels['time.empty.callSuffix']] })) : (labels['time.empty.callPlain'])] }));
409
+ return (_jsxs("p", { className: cardClass, children: [lead, ' ', phone ? (_jsxs(_Fragment, { children: [_jsx("a", { href: format.telHref(phone), "aria-label": fillLabel(labels['time.empty.callLinkAria'], { phone }), className: "font-semibold text-primary underline underline-offset-4", children: renderLabel(labels['time.empty.callLink'], { phone }) }), labels['time.empty.callSuffix']] })) : (labels['time.empty.callPlain'])] }));
409
410
  }
410
411
  /**
411
412
  * Today's remaining gaps sit inside the business's lead time, so they are never
@@ -4,7 +4,7 @@ import { Check } from 'lucide-react';
4
4
  import { useRef } from 'react';
5
5
  import { cn } from '../lib/utils.js';
6
6
  import { ADD_CHILD_SHEET_LABEL_KEYS, AddChildSheet, } from './add-child-sheet.js';
7
- import { fillLabel } from './labels.js';
7
+ import { renderLabel } from './internal/label-parts.js';
8
8
  import { slotClass } from './slots.js';
9
9
  /**
10
10
  * The first step — who is the visit for?
@@ -67,7 +67,7 @@ function choiceLabel(choice, labels) {
67
67
  return labels['who.guest.adult'];
68
68
  }
69
69
  const count = choice.people.length;
70
- return fillLabel(labels[count === 1 ? 'who.guest.children.one' : 'who.guest.children.other'], {
70
+ return renderLabel(labels[count === 1 ? 'who.guest.children.one' : 'who.guest.children.other'], {
71
71
  count,
72
72
  });
73
73
  }
@@ -104,10 +104,10 @@ export function WhoScreen({ labels, format, people, family, guestChoices, addedC
104
104
  return;
105
105
  onChoose([...seated, person], false);
106
106
  }
107
- return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [heading, _jsxs("fieldset", { className: "space-y-4", children: [_jsx("legend", { className: "mb-2 text-sm text-muted-foreground", children: fillLabel(labels['who.family.legend'], { max: maxPeople }) }), _jsxs("ul", { className: "space-y-2", children: [family.map((entry, index) => {
107
+ return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [heading, _jsxs("fieldset", { className: "space-y-4", children: [_jsx("legend", { className: "mb-2 text-sm text-muted-foreground", children: renderLabel(labels['who.family.legend'], { max: maxPeople }) }), _jsxs("ul", { className: "space-y-2", children: [family.map((entry, index) => {
108
108
  const selected = chosen.has(entry.person.key);
109
109
  return (_jsx("li", { children: _jsx(PersonCard, { name: entry.person.name ?? '', line: entry.line, index: index, selected: selected, disabled: !selected && full, describedBy: !selected && full ? LIMIT_ID : undefined, onToggle: () => toggle(entry.person), ...cardClasses }) }, entry.person.key));
110
- }), _jsx("li", { children: sheet(full, true) })] }), _jsxs(CheckRow, { checked: selfChecked, disabled: !selfChecked && full, describedBy: !selfChecked && full ? LIMIT_ID : undefined, onToggle: () => toggle(self), className: cn('h-14 justify-between px-5 text-sm', classNames?.card), selectedClassName: classNames?.cardSelected, children: [_jsx("span", { className: "font-medium", children: labels['who.family.self'] }), _jsx(CheckCircle, { checked: selfChecked })] }), _jsx("p", { id: LIMIT_ID, "aria-live": "polite", className: slotClass(classNames, 'limit', 'min-h-5 text-sm text-muted-foreground'), children: full ? fillLabel(labels['who.family.limit'], { max: maxPeople }) : '' })] })] }));
110
+ }), _jsx("li", { children: sheet(full, true) })] }), _jsxs(CheckRow, { checked: selfChecked, disabled: !selfChecked && full, describedBy: !selfChecked && full ? LIMIT_ID : undefined, onToggle: () => toggle(self), className: cn('h-14 justify-between px-5 text-sm', classNames?.card), selectedClassName: classNames?.cardSelected, children: [_jsx("span", { className: "font-medium", children: labels['who.family.self'] }), _jsx(CheckCircle, { checked: selfChecked })] }), _jsx("p", { id: LIMIT_ID, "aria-live": "polite", className: slotClass(classNames, 'limit', 'min-h-5 text-sm text-muted-foreground'), children: full ? renderLabel(labels['who.family.limit'], { max: maxPeople }) : '' })] })] }));
111
111
  }
112
112
  /**
113
113
  * The guest chips as a radio group. Roving focus: one chip is in the tab
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medalsocial/meda",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Shared Meda UI shell and runtime package.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -201,13 +201,13 @@
201
201
  }
202
202
  },
203
203
  "dependencies": {
204
- "@base-ui/react": "1.8.0",
205
- "class-variance-authority": "0.7.1",
206
- "clsx": "2.1.1",
207
- "cmdk": "1.1.1",
208
- "react-resizable-panels": "4.13.1",
209
- "tailwind-merge": "3.7.0",
210
- "vaul": "1.1.2"
204
+ "@base-ui/react": "^1.7.0",
205
+ "class-variance-authority": "^0.7.1",
206
+ "clsx": "^2.1.1",
207
+ "cmdk": "^1.1.1",
208
+ "react-resizable-panels": "^4.13.1",
209
+ "tailwind-merge": "^3.6.0",
210
+ "vaul": "^1.1.2"
211
211
  },
212
212
  "devDependencies": {
213
213
  "@biomejs/biome": "2.5.14",