@medalsocial/meda 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +35 -1
  2. package/dist/booking/add-child-sheet.d.ts +2 -1
  3. package/dist/booking/add-child-sheet.js +2 -1
  4. package/dist/booking/booking-skeleton.d.ts +2 -1
  5. package/dist/booking/confirmation.d.ts +2 -1
  6. package/dist/booking/confirmation.js +11 -11
  7. package/dist/booking/details-screen.d.ts +2 -1
  8. package/dist/booking/details-screen.js +13 -10
  9. package/dist/booking/index.d.ts +2 -1
  10. package/dist/booking/index.js +2 -1
  11. package/dist/booking/internal/label-parts.d.ts +39 -4
  12. package/dist/booking/internal/label-parts.js +78 -12
  13. package/dist/booking/label-keys.d.ts +2 -1
  14. package/dist/booking/labels.d.ts +37 -8
  15. package/dist/booking/labels.js +45 -8
  16. package/dist/booking/login-panel.d.ts +3 -2
  17. package/dist/booking/login-panel.js +8 -7
  18. package/dist/booking/login-sheet.d.ts +2 -1
  19. package/dist/booking/login-sheet.js +2 -1
  20. package/dist/booking/manage-screen.d.ts +2 -1
  21. package/dist/booking/manage-screen.js +30 -22
  22. package/dist/booking/otp-slots.d.ts +2 -1
  23. package/dist/booking/otp-slots.js +2 -1
  24. package/dist/booking/portal/age-confirm-card.d.ts +2 -1
  25. package/dist/booking/portal/age-confirm-card.js +3 -3
  26. package/dist/booking/portal/child-cards.d.ts +2 -1
  27. package/dist/booking/portal/child-cards.js +5 -5
  28. package/dist/booking/portal/data-controls.d.ts +2 -1
  29. package/dist/booking/portal/data-controls.js +5 -4
  30. package/dist/booking/portal/family-editor.d.ts +2 -1
  31. package/dist/booking/portal/family-editor.js +9 -9
  32. package/dist/booking/portal/logout-button.d.ts +2 -1
  33. package/dist/booking/portal/logout-button.js +2 -1
  34. package/dist/booking/portal/portal-shell.d.ts +2 -1
  35. package/dist/booking/portal/portal-shell.js +3 -2
  36. package/dist/booking/portal/portal-unreachable.d.ts +2 -1
  37. package/dist/booking/portal/portal-unreachable.js +2 -2
  38. package/dist/booking/portal/profile-form.d.ts +2 -1
  39. package/dist/booking/portal/profile-form.js +3 -2
  40. package/dist/booking/portal/rebook-cards.d.ts +2 -1
  41. package/dist/booking/portal/rebook-cards.js +3 -2
  42. package/dist/booking/portal/upcoming-bookings.d.ts +2 -1
  43. package/dist/booking/portal/upcoming-bookings.js +10 -14
  44. package/dist/booking/portal/vipps-link-row.d.ts +2 -1
  45. package/dist/booking/portal/vipps-link-row.js +7 -6
  46. package/dist/booking/portal/visit-history.d.ts +2 -1
  47. package/dist/booking/portal/visit-history.js +2 -2
  48. package/dist/booking/service-screen.d.ts +7 -3
  49. package/dist/booking/service-screen.js +9 -7
  50. package/dist/booking/stylist-screen.d.ts +15 -3
  51. package/dist/booking/stylist-screen.js +21 -17
  52. package/dist/booking/summary-bar.d.ts +2 -1
  53. package/dist/booking/time-screen.d.ts +2 -1
  54. package/dist/booking/time-screen.js +16 -15
  55. package/dist/booking/vipps-button.d.ts +2 -1
  56. package/dist/booking/vipps-button.js +2 -1
  57. package/dist/booking/who-screen.d.ts +2 -1
  58. package/dist/booking/who-screen.js +7 -6
  59. package/package.json +8 -8
  60. package/skills/components/SKILL.md +4 -0
@@ -3,15 +3,52 @@
3
3
  *
4
4
  * Every word a booking screen shows arrives through its `labels` prop — meda
5
5
  * ships no default brand and no default language. A screen's labels are a flat
6
- * record of strings keyed `'<screen>.<thing>'` (e.g. `'who.heading'`), so one
7
- * merged pack (`BookingLabels`) can be handed to every screen, and a customer
8
- * can override a single sentence without touching the rest.
6
+ * record keyed `'<screen>.<thing>'` (e.g. `'who.heading'`), so one merged pack
7
+ * (`BookingLabels`) can be handed to every screen, and a customer can override
8
+ * a single sentence without touching the rest.
9
9
  *
10
- * Values are plain strings, so a pack is serialisable (it can travel from a
11
- * server component to the client as a prop). Variable parts are `{name}`
12
- * placeholders, filled by `fillLabel`.
10
+ * A label is a `BookingLabel`: a string, or an array of strings. Both are
11
+ * serialisable, so a pack can travel from a server component to the client as
12
+ * a prop. Variable parts are `{name}` placeholders.
13
+ *
14
+ * The two forms differ only in the DOM they render as element children:
15
+ *
16
+ * - a **string** renders as ONE text node once filled, like a template
17
+ * literal: `'Total {total} · paid at the salon'` → «Total 840 kr · paid at
18
+ * the salon», one node;
19
+ * - an **array** renders one text node per element, each element filled with
20
+ * the same values: `['Total ', '{total}', ' · ', 'paid at the salon']` →
21
+ * four nodes, the same DOM as `Total {total}{' · '}paid at the salon` in JSX.
22
+ * An element that fills to `''` renders nothing.
23
+ *
24
+ * A browser lays text out per text node, so the same sentence split
25
+ * differently can land its later glyphs a sub-pixel apart. The pack decides
26
+ * where a sentence breaks; a screen never splits a string on its own.
27
+ *
28
+ * Text-only targets (`aria-label`, `title`, a file name, an error string) read
29
+ * a label through `fillLabel` / `labelText`, which join an array's elements.
13
30
  */
14
- /** Replaces `{key}` placeholders in `template`. Unknown keys are left as written. */
15
- export function fillLabel(template, values) {
31
+ /** A label as plain text: an array's elements joined, a string as it is. */
32
+ export function labelText(label) {
33
+ return typeof label === 'string' ? label : label.join('');
34
+ }
35
+ function fillOne(template, values) {
16
36
  return template.replace(/\{(\w+)\}/g, (match, key) => Object.hasOwn(values, key) ? String(values[key]) : match);
17
37
  }
38
+ /**
39
+ * Replaces `{key}` placeholders in `template`, as plain text. An array is
40
+ * filled element by element and joined. Unknown keys are left as written.
41
+ */
42
+ export function fillLabel(template, values) {
43
+ return typeof template === 'string'
44
+ ? fillOne(template, values)
45
+ : template.map((piece) => fillOne(piece, values)).join('');
46
+ }
47
+ /**
48
+ * The text nodes a label fills to: one for a string, one per element for an
49
+ * array, with empty ones dropped. `[]` when the label fills to nothing.
50
+ */
51
+ export function fillLabelPieces(template, values) {
52
+ const pieces = typeof template === 'string' ? [template] : template;
53
+ return pieces.map((piece) => fillOne(piece, values)).filter((piece) => piece !== '');
54
+ }
@@ -1,4 +1,5 @@
1
1
  import { type Dispatch, type ReactNode, type SetStateAction } from 'react';
2
+ import type { BookingLabel } from './labels.js';
2
3
  import { type OtpSlotsLabels, type OtpSlotsSlot } from './otp-slots.js';
3
4
  import { type SlotClassNames } from './slots.js';
4
5
  import type { BookingGuardian } from './types.js';
@@ -9,7 +10,7 @@ export declare const LOGIN_PANEL_LABEL_KEYS: readonly ["login.heading", "login.c
9
10
  * Placeholders: `login.codeHelp` `{email}`, `login.vippsSentTo` `{to}`,
10
11
  * `login.cooldownWait` `{seconds}`, `login.resendIn` `{time}` («0:30»).
11
12
  */
12
- export type LoginPanelLabels = Record<(typeof LOGIN_PANEL_OWN_LABEL_KEYS)[number], string> & OtpSlotsLabels & VippsButtonLabels;
13
+ export type LoginPanelLabels = Record<(typeof LOGIN_PANEL_OWN_LABEL_KEYS)[number], BookingLabel> & OtpSlotsLabels & VippsButtonLabels;
13
14
  /**
14
15
  * - `root`: the panel's column.
15
16
  * - `notice`: a non-error sentence in the live region (errors use `Field.Error`).
@@ -82,7 +83,7 @@ export interface LoginPanelProps {
82
83
  memory?: LoginPanelMemory;
83
84
  onMemoryChange?: Dispatch<SetStateAction<LoginPanelMemory>>;
84
85
  /** Draws the heading («Log in» / «Check your e-mail»). Default: none — the page has its own. */
85
- heading?: (text: string) => ReactNode;
86
+ heading?: (text: ReactNode) => ReactNode;
86
87
  /** Drawn above the Vipps button on the first screen. */
87
88
  intro?: ReactNode;
88
89
  /** Wait between codes. Default 30 s. */
@@ -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';
@@ -211,7 +211,8 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
211
211
  setNotice(null);
212
212
  }
213
213
  const isFieldError = notice !== null && FIELD_ERRORS.has(notice);
214
- const noticeText = notice === null ? null : labels[`login.notice.${notice}`];
214
+ // `null` for a blank notice, so no input is described by an empty one.
215
+ const noticeText = notice === null ? null : renderLabel(labels[`login.notice.${notice}`]);
215
216
  // One live region for the whole flow, ABOVE the switch between the two
216
217
  // screens so it stays mounted across it: a region that arrives already
217
218
  // holding its sentence is often read as nothing at all. It doubles as the
@@ -221,19 +222,19 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
221
222
  // The countdown on the button changes every second; this region says only
222
223
  // the two things worth hearing — how long, and that it is over.
223
224
  const cooldownSentence = waitSeconds > 0
224
- ? fillLabel(labels['login.cooldownWait'], { seconds: Math.ceil(resendCooldownMs / 1000) })
225
+ ? renderLabel(labels['login.cooldownWait'], { seconds: Math.ceil(resendCooldownMs / 1000) })
225
226
  : labels['login.cooldownReady'];
226
227
  const cooldownRegion = (_jsx("p", { "aria-live": "polite", className: "sr-only", children: mode === 'code' ? cooldownSentence : null }));
227
228
  const link = slotClass(classNames, 'link', 'font-semibold text-primary underline underline-offset-4 disabled:no-underline disabled:opacity-60');
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
+ return (_jsxs("div", { className: slotClass(classNames, 'root', 'space-y-6'), children: [heading(renderLabel(labels[mode === 'start' ? 'login.heading' : 'login.codeHeading'])), mode === 'vipps' && (_jsx("p", { className: "text-muted-foreground", children: sentTo === ''
229
230
  ? 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,
231
+ : 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
232
  // Read-only rather than disabled while a request is out, so
232
233
  // the caret stays in the field instead of falling out of a
233
234
  // dialog; a wrong code then only has to empty it.
234
235
  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
236
  ? 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) })
237
+ : 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
238
+ ? renderLabel(labels['login.resendIn'], { time: timer(waitSeconds) })
238
239
  : labels['login.resend'] })), _jsx("button", { type: "button", disabled: pending, onClick: switchEmail, className: link, children: mode === 'vipps' ? labels['login.useEmailInstead'] : labels['login.switchEmail'] })] })] }))] }));
239
240
  }
@@ -1,10 +1,11 @@
1
+ import { type BookingLabel } from './labels.js';
1
2
  import { type LoginPanelLabels, type LoginPanelProps } from './login-panel.js';
2
3
  import { type SlotClassNames } from './slots.js';
3
4
  import type { BookingGuardian } from './types.js';
4
5
  export declare const LOGIN_SHEET_OWN_LABEL_KEYS: readonly ["loginSheet.prompt", "loginSheet.trigger", "loginSheet.intro", "loginSheet.vipps", "loginSheet.close"];
5
6
  export declare const LOGIN_SHEET_LABEL_KEYS: readonly ["loginSheet.prompt", "loginSheet.trigger", "loginSheet.intro", "loginSheet.vipps", "loginSheet.close", "login.heading", "login.codeHeading", "login.or", "login.emailIntro", "login.emailLabel", "login.sendCode", "login.codeLabel", "login.submitCode", "login.codeHelp", "login.codeHelpVipps", "login.vippsSentTo", "login.vippsSentToUnknown", "login.cooldownWait", "login.cooldownReady", "login.resendIn", "login.resend", "login.switchEmail", "login.useEmailInstead", "login.notice.sent", "login.notice.resent", "login.notice.unreachable", "login.notice.badEmail", "login.notice.invalid", "login.notice.throttled", "login.notice.vippsInvalid", "login.notice.vippsConflict", "otp.label", "vipps.button", "vipps.unavailable", "vipps.throttled"];
6
7
  /** `loginSheet.prompt` holds a `{trigger}` placeholder where the trigger button goes. */
7
- export type LoginSheetLabels = Record<(typeof LOGIN_SHEET_OWN_LABEL_KEYS)[number], string> & LoginPanelLabels;
8
+ export type LoginSheetLabels = Record<(typeof LOGIN_SHEET_OWN_LABEL_KEYS)[number], BookingLabel> & LoginPanelLabels;
8
9
  /**
9
10
  * - `prompt`: the «have an account? Log in» row.
10
11
  * - `trigger`: the button inside it.
@@ -5,6 +5,7 @@ import { useState } from 'react';
5
5
  import { Sheet } from '../primitives/sheet.js';
6
6
  import { labelParts } from './internal/label-parts.js';
7
7
  import { BookingButton } from './internal/ui.js';
8
+ import { labelText } from './labels.js';
8
9
  import { LOGIN_PANEL_LABEL_KEYS, LoginPanel, loginMemoryFor, } from './login-panel.js';
9
10
  import { slotClass } from './slots.js';
10
11
  export const LOGIN_SHEET_OWN_LABEL_KEYS = [
@@ -41,7 +42,7 @@ export function LoginSheet({ labels, onStartLogin, onVerify, onVipps, onSignedIn
41
42
  const [signedIn, setSignedIn] = useState(false);
42
43
  return (_jsxs(Sheet, { open: open, onOpenChange: setOpen, children: [trigger && !signedIn && (_jsx("p", { className: slotClass(classNames, 'prompt', 'text-sm text-muted-foreground'), children: labelParts(labels['loginSheet.prompt'], {
43
44
  trigger: (_jsx(Sheet.Trigger, { className: slotClass(classNames, 'trigger', 'font-semibold text-primary underline underline-offset-4'), children: labels['loginSheet.trigger'] })),
44
- }) })), _jsxs(Sheet.Content, { className: slotClass(classNames, 'content', 'z-50'), children: [_jsx(LoginPanel, { labels: labels, memory: memory, onMemoryChange: setMemory, onStartLogin: onStartLogin, onVerify: onVerify, onVipps: onVipps, vippsNext: resumePath, vippsLabel: labels['loginSheet.vipps'], resendCooldownMs: resendCooldownMs, classNames: panelClassNames, otpClassNames: otpClassNames, vippsClassNames: vippsClassNames, onSignedIn: (guardian) => {
45
+ }) })), _jsxs(Sheet.Content, { className: slotClass(classNames, 'content', 'z-50'), children: [_jsx(LoginPanel, { labels: labels, memory: memory, onMemoryChange: setMemory, onStartLogin: onStartLogin, onVerify: onVerify, onVipps: onVipps, vippsNext: resumePath, vippsLabel: labelText(labels['loginSheet.vipps']), resendCooldownMs: resendCooldownMs, classNames: panelClassNames, otpClassNames: otpClassNames, vippsClassNames: vippsClassNames, onSignedIn: (guardian) => {
45
46
  setSignedIn(true);
46
47
  setOpen(false);
47
48
  onSignedIn(guardian);
@@ -1,4 +1,5 @@
1
1
  import type { BookingDaypart, BookingFormat } from './format.js';
2
+ import { type BookingLabel } from './labels.js';
2
3
  import { type SlotClassNames } from './slots.js';
3
4
  import { type TimeScreenComponents, type TimeScreenLabels, type TimeScreenSlot } from './time-screen.js';
4
5
  import type { BookingDayDto, BookingManageDto, BookingSlotDto } from './types.js';
@@ -33,7 +34,7 @@ export declare const MANAGE_SCREEN_LABEL_KEYS: readonly ["manage.heading", "mana
33
34
  * The three `cancel.reason.*` strings are both the chips' text and the value
34
35
  * `onCancel` receives.
35
36
  */
36
- export type ManageScreenLabels = Record<(typeof MANAGE_SCREEN_LABEL_KEYS)[number], string> & TimeScreenLabels;
37
+ export type ManageScreenLabels = Record<(typeof MANAGE_SCREEN_LABEL_KEYS)[number], BookingLabel> & TimeScreenLabels;
37
38
  /** Why a mutation failed. Map anything the route did not name to `unavailable`. */
38
39
  export type ManageErrorCode = 'windowPassed' | 'slotTaken' | 'notFound' | 'conflict' | 'invalidInput' | 'unavailable';
39
40
  /**
@@ -2,8 +2,9 @@
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
- import { fillLabel } from './labels.js';
7
+ import { fillLabel, labelText } from './labels.js';
7
8
  import { slotClass } from './slots.js';
8
9
  import { TimeScreen, } from './time-screen.js';
9
10
  /**
@@ -120,7 +121,7 @@ const LINK_CLASS = 'font-semibold text-primary underline underline-offset-4';
120
121
  /** «Name's appointment» when a name was given, the bare noun when not. */
121
122
  function theirAppointment(labels, name) {
122
123
  if (!name)
123
- return labels['manage.appointment.unnamed'];
124
+ return labelText(labels['manage.appointment.unnamed']);
124
125
  return fillLabel(labels[/[sxz]$/i.test(name) ? 'manage.appointment.namedSibilant' : 'manage.appointment.named'], { name });
125
126
  }
126
127
  /**
@@ -141,16 +142,20 @@ function remainingLabel(labels, ms) {
141
142
  return fillLabel(labels['manage.remaining.in'], { amount: hourPart });
142
143
  const minutes = Math.floor((ms % HOUR_MS) / 60_000);
143
144
  if (minutes < 1)
144
- return labels['manage.remaining.underMinute'];
145
+ return labelText(labels['manage.remaining.underMinute']);
145
146
  return fillLabel(labels['manage.remaining.in'], {
146
147
  amount: fillLabel(labels[minutes === 1 ? 'manage.remaining.minute.one' : 'manage.remaining.minute.other'], { count: minutes }),
147
148
  });
148
149
  }
149
150
  function ringUs(labels, phone) {
150
- return phone ? fillLabel(labels['manage.ringUsOn'], { phone }) : labels['manage.ringUs'];
151
+ return phone
152
+ ? fillLabel(labels['manage.ringUsOn'], { phone })
153
+ : labelText(labels['manage.ringUs']);
151
154
  }
152
155
  function callUs(labels, phone) {
153
- return phone ? fillLabel(labels['manage.callUsOn'], { phone }) : labels['manage.callUs'];
156
+ return phone
157
+ ? fillLabel(labels['manage.callUsOn'], { phone })
158
+ : labelText(labels['manage.callUs']);
154
159
  }
155
160
  /** What the visitor is told went wrong — never the upstream's own words. */
156
161
  function errorSentence(labels, code, phone) {
@@ -158,13 +163,13 @@ function errorSentence(labels, code, phone) {
158
163
  case 'windowPassed':
159
164
  return fillLabel(labels['manage.error.windowPassed'], { ringUs: ringUs(labels, phone) });
160
165
  case 'slotTaken':
161
- return labels['manage.error.slotTaken'];
166
+ return labelText(labels['manage.error.slotTaken']);
162
167
  case 'notFound':
163
168
  return fillLabel(labels['manage.error.notFound'], { callUs: callUs(labels, phone) });
164
169
  case 'conflict':
165
170
  return fillLabel(labels['manage.error.conflict'], { ringUs: ringUs(labels, phone) });
166
171
  case 'invalidInput':
167
- return labels['manage.error.invalidInput'];
172
+ return labelText(labels['manage.error.invalidInput']);
168
173
  default:
169
174
  return fillLabel(labels['manage.error.unavailable'], { ringUs: ringUs(labels, phone) });
170
175
  }
@@ -199,7 +204,7 @@ export function ManageScreen({ labels, format, dayparts, booking, phone = null,
199
204
  ? null
200
205
  : format.stylistName(booking.resourceName) || null;
201
206
  const serviceLabel = booking.serviceName === UNKNOWN_SERVICE
202
- ? labels['manage.serviceFallback']
207
+ ? labelText(labels['manage.serviceFallback'])
203
208
  : booking.serviceName;
204
209
  // The LARGER window wins: the sentence promises both actions, and the earlier
205
210
  // of the two deadlines is the one true of both.
@@ -271,14 +276,14 @@ export function ManageScreen({ labels, format, dayparts, booking, phone = null,
271
276
  const panelClass = (base) => slotClass(classNames, 'panel', base);
272
277
  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
278
  ? labels['manage.moved']
274
- : fillLabel(labels['manage.movedTo'], {
279
+ : renderLabel(labels['manage.movedTo'], {
275
280
  dateTime: format.clock.dateTime(movedTo),
276
281
  }) }), 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'], {
282
+ ? renderLabel(labels['manage.card.named'], {
278
283
  name: booking.bookedForName,
279
284
  service: serviceLabel,
280
285
  })
281
- : serviceLabel }), _jsx("p", { className: "tabular-nums", children: fillLabel(labels[stylist ? 'manage.card.whenWithStylist' : 'manage.card.when'], {
286
+ : serviceLabel }), _jsx("p", { className: "tabular-nums", children: renderLabel(labels[stylist ? 'manage.card.whenWithStylist' : 'manage.card.when'], {
282
287
  dateTime: format.clock.dateTime(booking.startTs),
283
288
  stylist: stylist ?? '',
284
289
  price: format.price(booking.amountOre),
@@ -287,11 +292,11 @@ export function ManageScreen({ labels, format, dayparts, booking, phone = null,
287
292
  setError(null);
288
293
  setView('cancel');
289
294
  } })) : (_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'], {
295
+ ? renderLabel(labels['manage.reschedule.leadWithStylist'], {
291
296
  service: serviceLabel,
292
297
  stylist,
293
298
  })
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) => {
299
+ : 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
300
  setPending(slot);
296
301
  setError(null);
297
302
  setView('move');
@@ -311,13 +316,15 @@ function Overview({ labels, format, booking, freeUntilTs, remaining, phone, icsH
311
316
  // The largest window that has already closed — the number the sentence below
312
317
  // quotes. Zero when both are open, and then it is not rendered.
313
318
  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'])] }))] }));
319
+ return (_jsxs(_Fragment, { children: [booking.canCancel && booking.canReschedule && (_jsxs("p", { className: "text-sm text-muted-foreground", children: [renderLabel(labels['manage.freeUntil'], {
320
+ dateTime: format.clock.dateTime(freeUntilTs),
321
+ }), 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
322
  }
316
323
  /** A booking no longer open to either action, saying which kind it is. */
317
324
  function ClosedBooking({ labels, format, status, bookingHref, phone, className, }) {
318
325
  const sentence = labels[CLOSED_SENTENCE[status] ?? 'manage.closed.pending'];
319
326
  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 })
327
+ ? renderLabel(labels['manage.closed.pendingCall'], { phone })
321
328
  : labels['manage.closed.pendingCallPlain'] }) })) : (_jsx("a", { href: bookingHref, className: LINK_CLASS, children: labels['manage.bookNew'] }))] }));
322
329
  }
323
330
  /**
@@ -327,8 +334,8 @@ function ClosedBooking({ labels, format, status, bookingHref, phone, className,
327
334
  * contiguous, which the sub-line says.
328
335
  */
329
336
  function PartyChoice({ labels, format, bookedForName, phone, onJustThisOne, onBack, className, }) {
330
- 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']] })] }));
337
+ const who = bookedForName ?? labelText(labels['manage.party.unnamed']);
338
+ 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
339
  }
333
340
  /**
334
341
  * The single confirm for a move. The weekend note appears only when the new
@@ -340,13 +347,13 @@ function ConfirmMove({ labels, format, booking, pricing, stylist, pending, busy,
340
347
  const nextOre = pricing ? pricing.priceAt(pending.startTs) : 0;
341
348
  const nowOre = pricing ? pricing.priceAt(booking.startTs) : 0;
342
349
  const dearer = pricing !== null && nextOre > nowOre;
343
- const question = fillLabel(labels[stylist ? 'manage.move.questionWithStylist' : 'manage.move.question'], {
350
+ const question = renderLabel(labels[stylist ? 'manage.move.questionWithStylist' : 'manage.move.question'], {
344
351
  appointment: theirAppointment(labels, booking.bookedForName),
345
352
  from: `${clock.date(booking.startTs)} ${clock.formatTime(booking.startTs)}`,
346
353
  to: `${clock.date(pending.startTs)} ${clock.formatTime(pending.startTs)}`,
347
354
  stylist: stylist ?? '',
348
355
  });
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'], {
356
+ 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
357
  weekday: clock.weekday(pending.startTs),
351
358
  price: format.price(nextOre),
352
359
  pct: pricing.weekendSurchargePct,
@@ -354,16 +361,17 @@ function ConfirmMove({ labels, format, booking, pricing, stylist, pending, busy,
354
361
  }
355
362
  /** The single confirm for a cancellation, with the reason asked and never required. */
356
363
  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'], {
364
+ return (_jsxs("div", { className: className, children: [_jsx("p", { className: "font-medium", children: renderLabel(labels['manage.cancel.question'], {
358
365
  appointment: theirAppointment(labels, booking.bookedForName),
359
366
  dateTime: format.clock.dateTime(booking.startTs),
360
367
  hours: booking.cancelWindowHours,
361
368
  }) }), _jsxs("fieldset", { className: "flex flex-wrap gap-2", children: [_jsx("legend", { className: "sr-only", children: labels['manage.cancel.reasonLegend'] }), CANCEL_REASON_KEYS.map((key) => {
362
- const option = labels[key];
369
+ // The reason travels as text; the chip shows the label in its own pieces.
370
+ const option = labelText(labels[key]);
363
371
  const picked = reason === option;
364
372
  return (_jsx(BookingButton, { variant: "outline", size: "sm", "aria-pressed": picked,
365
373
  // Tapping the chosen chip again clears it: the answer is
366
374
  // optional, so there has to be a way back to having given none.
367
- onClick: () => onPickReason(picked ? null : option), className: cn('rounded-full', picked ? 'border-primary ring-2 ring-primary' : ''), children: option }, key));
375
+ onClick: () => onPickReason(picked ? null : option), className: cn('rounded-full', picked ? 'border-primary ring-2 ring-primary' : ''), children: labels[key] }, key));
368
376
  })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-4", children: [_jsx(BookingButton, { size: "lg", disabled: busy, onClick: onConfirm, children: labels['manage.cancel.confirm'] }), _jsx(BookingButton, { size: "lg", variant: "outline", disabled: busy, onClick: onBack, children: labels['manage.cancel.keep'] })] })] }));
369
377
  }
@@ -1,6 +1,7 @@
1
+ import { type BookingLabel } from './labels.js';
1
2
  import { type SlotClassNames } from './slots.js';
2
3
  export declare const OTP_SLOTS_LABEL_KEYS: readonly ["otp.label"];
3
- export type OtpSlotsLabels = Record<(typeof OTP_SLOTS_LABEL_KEYS)[number], string>;
4
+ export type OtpSlotsLabels = Record<(typeof OTP_SLOTS_LABEL_KEYS)[number], BookingLabel>;
4
5
  /**
5
6
  * - `root`: the positioned wrapper.
6
7
  * - `row`: the six-box grid.
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId, useLayoutEffect, useRef, useState, } from 'react';
4
4
  import { cn } from '../lib/utils.js';
5
+ import { labelText } from './labels.js';
5
6
  import { slotClass } from './slots.js';
6
7
  const LENGTH = 6;
7
8
  function digitsOnly(value) {
@@ -98,7 +99,7 @@ export function OtpSlots({ id, name = 'code', value, disabled = false, readOnly
98
99
  return (_jsxs("div", { className: slotClass(classNames, 'root', 'relative'), children: [_jsx("input", { ref: hiddenRef, id: fieldId, name: name, inputMode: "numeric", autoComplete: "one-time-code", autoCorrect: "off", spellCheck: false,
99
100
  // `\d*` rather than `[0-9]{6}`: it is the spelling iOS reads as «show the
100
101
  // number pad», and the length is `minLength`/`maxLength`'s job.
101
- pattern: "\\d*", minLength: LENGTH, maxLength: LENGTH, required: true, disabled: disabled, readOnly: readOnly, "aria-busy": readOnly || undefined, value: digits, "aria-invalid": invalid || undefined, "aria-describedby": describedBy, "aria-label": labels['otp.label'], onChange: handleChange, onKeyDown: handleKeyDown, onKeyUp: (event) => syncCaret(event.currentTarget), onSelect: (event) => syncCaret(event.currentTarget), onClick: (event) => syncCaret(event.currentTarget), onPaste: handlePaste, className: "absolute inset-0 z-10 h-full w-full cursor-text opacity-0" }), _jsx("div", { "aria-hidden": "true", "data-slot": "otp-row", className: slotClass(classNames, 'row', 'grid grid-cols-6 gap-1.5 sm:gap-2.5'), onMouseDown: (event) => {
102
+ pattern: "\\d*", minLength: LENGTH, maxLength: LENGTH, required: true, disabled: disabled, readOnly: readOnly, "aria-busy": readOnly || undefined, value: digits, "aria-invalid": invalid || undefined, "aria-describedby": describedBy, "aria-label": labelText(labels['otp.label']), onChange: handleChange, onKeyDown: handleKeyDown, onKeyUp: (event) => syncCaret(event.currentTarget), onSelect: (event) => syncCaret(event.currentTarget), onClick: (event) => syncCaret(event.currentTarget), onPaste: handlePaste, className: "absolute inset-0 z-10 h-full w-full cursor-text opacity-0" }), _jsx("div", { "aria-hidden": "true", "data-slot": "otp-row", className: slotClass(classNames, 'row', 'grid grid-cols-6 gap-1.5 sm:gap-2.5'), onMouseDown: (event) => {
102
103
  event.preventDefault();
103
104
  focusHidden();
104
105
  }, children: Array.from({ length: LENGTH }, (_, index) => {
@@ -1,7 +1,8 @@
1
+ import type { BookingLabel } from '../labels.js';
1
2
  import { type SlotClassNames } from '../slots.js';
2
3
  export declare const AGE_CONFIRM_CARD_LABEL_KEYS: readonly ["ageConfirm.heading", "ageConfirm.lead", "ageConfirm.legend", "ageConfirm.birthYear", "ageConfirm.birthMonth", "ageConfirm.birthMonthUnknown", "ageConfirm.confirm", "ageConfirm.dismiss"];
3
4
  /** `heading` and `legend` take `{name}`. */
4
- export type AgeConfirmCardLabels = Record<(typeof AGE_CONFIRM_CARD_LABEL_KEYS)[number], string>;
5
+ export type AgeConfirmCardLabels = Record<(typeof AGE_CONFIRM_CARD_LABEL_KEYS)[number], BookingLabel>;
5
6
  /** `root` (the section), `heading`, `form`, `actions` (the button row). */
6
7
  export type AgeConfirmCardSlot = 'root' | 'heading' | 'form' | 'actions';
7
8
  export interface AgeConfirmCardProps {
@@ -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
  }
@@ -1,5 +1,6 @@
1
1
  import type { ComponentType, ReactNode } from 'react';
2
2
  import type { BookingFormat } from '../format.js';
3
+ import type { BookingLabel } from '../labels.js';
3
4
  import { type SlotClassNames } from '../slots.js';
4
5
  import type { ChildSummary } from '../types.js';
5
6
  export declare const CHILD_CARDS_LABEL_KEYS: readonly ["childCards.empty", "childCards.neverVisited", "childCards.lastVisit", "childCards.lastVisitNoService", "childCards.book", "childCards.stylist", "childCards.next", "childCards.none"];
@@ -8,7 +9,7 @@ export declare const CHILD_CARDS_LABEL_KEYS: readonly ["childCards.empty", "chil
8
9
  * `{age}` `{service}` `{date}`; `childCards.lastVisitNoService` `{age}`
9
10
  * `{date}`; `childCards.book` `{name}`.
10
11
  */
11
- export type ChildCardsLabels = Record<(typeof CHILD_CARDS_LABEL_KEYS)[number], string>;
12
+ export type ChildCardsLabels = Record<(typeof CHILD_CARDS_LABEL_KEYS)[number], BookingLabel>;
12
13
  /**
13
14
  * - `list`: the grid. - `card`: one child. - `avatar`: the initial.
14
15
  * - `name`, `detail`: the two lines. - `details`: the full card's facts.
@@ -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
@@ -1,3 +1,4 @@
1
+ import { type BookingLabel } from '../labels.js';
1
2
  import { type SlotClassNames } from '../slots.js';
2
3
  import { type PortalActionFailure, type PortalActionResult } from './action-result.js';
3
4
  export declare const DATA_CONTROLS_LABEL_KEYS: readonly ["dataControls.heading", "dataControls.export", "dataControls.delete", "dataControls.confirmHeading", "dataControls.consequences", "dataControls.confirmLabel", "dataControls.confirmHelp", "dataControls.wrongWord", "dataControls.deleteForever", "dataControls.cancel", "dataControls.unreachable", "dataControls.confirmWord"];
@@ -5,7 +6,7 @@ export declare const DATA_CONTROLS_LABEL_KEYS: readonly ["dataControls.heading",
5
6
  * `confirmWord` is the literal the visitor must type (e.g. «DELETE»);
6
7
  * `confirmLabel` and `wrongWord` take `{word}`.
7
8
  */
8
- export type DataControlsLabels = Record<(typeof DATA_CONTROLS_LABEL_KEYS)[number], string>;
9
+ export type DataControlsLabels = Record<(typeof DATA_CONTROLS_LABEL_KEYS)[number], BookingLabel>;
9
10
  /**
10
11
  * `root` (the section), `heading`, `actions` (the export/delete row),
11
12
  * `confirm` (the confirm panel form).
@@ -3,8 +3,9 @@ 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
- import { fillLabel } from '../labels.js';
8
+ import { fillLabel, labelText } from '../labels.js';
8
9
  import { slotClass } from '../slots.js';
9
10
  import { settle } from './action-result.js';
10
11
  export const DATA_CONTROLS_LABEL_KEYS = [
@@ -50,7 +51,7 @@ export function DataControls({ labels, onExport, onDelete, onSessionExpired, cla
50
51
  const [error, setError] = useState(null);
51
52
  const [exportError, setExportError] = useState(null);
52
53
  const [pending, startTransition] = useTransition();
53
- const word = labels['dataControls.confirmWord'];
54
+ const word = labelText(labels['dataControls.confirmWord']);
54
55
  const confirmId = `${id}-confirm`;
55
56
  const errorId = `${id}-error`;
56
57
  function failed(failure) {
@@ -58,7 +59,7 @@ export function DataControls({ labels, onExport, onDelete, onSessionExpired, cla
58
59
  onSessionExpired?.();
59
60
  return null;
60
61
  }
61
- return failure.message ?? labels['dataControls.unreachable'];
62
+ return failure.message ?? labelText(labels['dataControls.unreachable']);
62
63
  }
63
64
  function exportData() {
64
65
  setExportError(null);
@@ -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,6 @@
1
1
  import { type ComponentType } from 'react';
2
2
  import type { BookingFormat } from '../format.js';
3
+ import { type BookingLabel } from '../labels.js';
3
4
  import { type SlotClassNames } from '../slots.js';
4
5
  import type { PortalFamilyMemberDto, PortalPersonNouns } from '../types.js';
5
6
  import { type PortalActionFailure } from './action-result.js';
@@ -10,7 +11,7 @@ export declare const FAMILY_EDITOR_LABEL_KEYS: readonly ["familyEditor.heading",
10
11
  * `heading` is used when no `nouns` are given. `removeNamed`, `ageConfirmed`
11
12
  * and `ageSaved` take `{name}`. The card's `ageConfirm.*` keys are included.
12
13
  */
13
- export type FamilyEditorLabels = Record<(typeof FAMILY_EDITOR_OWN_LABEL_KEYS)[number], string> & AgeConfirmCardLabels;
14
+ export type FamilyEditorLabels = Record<(typeof FAMILY_EDITOR_OWN_LABEL_KEYS)[number], BookingLabel> & AgeConfirmCardLabels;
14
15
  /**
15
16
  * `root` (the section), `heading`, `list` (the `<ul>` of rows), `card` (each
16
17
  * row's form), `listStatus` (the live region under the list), `add` (the add