@medalsocial/meda 3.3.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 (56) 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 +2 -2
  7. package/dist/booking/details-screen.d.ts +2 -1
  8. package/dist/booking/details-screen.js +9 -8
  9. package/dist/booking/index.d.ts +2 -2
  10. package/dist/booking/index.js +2 -2
  11. package/dist/booking/internal/label-parts.d.ts +36 -21
  12. package/dist/booking/internal/label-parts.js +71 -43
  13. package/dist/booking/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 +3 -2
  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 +16 -11
  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/child-cards.d.ts +2 -1
  26. package/dist/booking/portal/data-controls.d.ts +2 -1
  27. package/dist/booking/portal/data-controls.js +3 -3
  28. package/dist/booking/portal/family-editor.d.ts +2 -1
  29. package/dist/booking/portal/family-editor.js +9 -9
  30. package/dist/booking/portal/logout-button.d.ts +2 -1
  31. package/dist/booking/portal/logout-button.js +2 -1
  32. package/dist/booking/portal/portal-shell.d.ts +2 -1
  33. package/dist/booking/portal/portal-shell.js +3 -2
  34. package/dist/booking/portal/portal-unreachable.d.ts +2 -1
  35. package/dist/booking/portal/profile-form.d.ts +2 -1
  36. package/dist/booking/portal/profile-form.js +3 -2
  37. package/dist/booking/portal/rebook-cards.d.ts +2 -1
  38. package/dist/booking/portal/rebook-cards.js +3 -2
  39. package/dist/booking/portal/upcoming-bookings.d.ts +2 -1
  40. package/dist/booking/portal/upcoming-bookings.js +3 -3
  41. package/dist/booking/portal/vipps-link-row.d.ts +2 -1
  42. package/dist/booking/portal/vipps-link-row.js +7 -6
  43. package/dist/booking/portal/visit-history.d.ts +2 -1
  44. package/dist/booking/service-screen.d.ts +2 -1
  45. package/dist/booking/service-screen.js +2 -2
  46. package/dist/booking/stylist-screen.d.ts +2 -1
  47. package/dist/booking/stylist-screen.js +7 -6
  48. package/dist/booking/summary-bar.d.ts +2 -1
  49. package/dist/booking/time-screen.d.ts +2 -1
  50. package/dist/booking/time-screen.js +6 -6
  51. package/dist/booking/vipps-button.d.ts +2 -1
  52. package/dist/booking/vipps-button.js +2 -1
  53. package/dist/booking/who-screen.d.ts +2 -1
  54. package/dist/booking/who-screen.js +3 -2
  55. package/package.json +1 -1
  56. 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. */
@@ -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
@@ -225,7 +226,7 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
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
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
@@ -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
  /**
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
4
4
  import { cn } from '../lib/utils.js';
5
5
  import { renderLabel } from './internal/label-parts.js';
6
6
  import { BookingButton } from './internal/ui.js';
7
- import { fillLabel } from './labels.js';
7
+ import { fillLabel, labelText } from './labels.js';
8
8
  import { slotClass } from './slots.js';
9
9
  import { TimeScreen, } from './time-screen.js';
10
10
  /**
@@ -121,7 +121,7 @@ const LINK_CLASS = 'font-semibold text-primary underline underline-offset-4';
121
121
  /** «Name's appointment» when a name was given, the bare noun when not. */
122
122
  function theirAppointment(labels, name) {
123
123
  if (!name)
124
- return labels['manage.appointment.unnamed'];
124
+ return labelText(labels['manage.appointment.unnamed']);
125
125
  return fillLabel(labels[/[sxz]$/i.test(name) ? 'manage.appointment.namedSibilant' : 'manage.appointment.named'], { name });
126
126
  }
127
127
  /**
@@ -142,16 +142,20 @@ function remainingLabel(labels, ms) {
142
142
  return fillLabel(labels['manage.remaining.in'], { amount: hourPart });
143
143
  const minutes = Math.floor((ms % HOUR_MS) / 60_000);
144
144
  if (minutes < 1)
145
- return labels['manage.remaining.underMinute'];
145
+ return labelText(labels['manage.remaining.underMinute']);
146
146
  return fillLabel(labels['manage.remaining.in'], {
147
147
  amount: fillLabel(labels[minutes === 1 ? 'manage.remaining.minute.one' : 'manage.remaining.minute.other'], { count: minutes }),
148
148
  });
149
149
  }
150
150
  function ringUs(labels, phone) {
151
- return phone ? fillLabel(labels['manage.ringUsOn'], { phone }) : labels['manage.ringUs'];
151
+ return phone
152
+ ? fillLabel(labels['manage.ringUsOn'], { phone })
153
+ : labelText(labels['manage.ringUs']);
152
154
  }
153
155
  function callUs(labels, phone) {
154
- return phone ? fillLabel(labels['manage.callUsOn'], { phone }) : labels['manage.callUs'];
156
+ return phone
157
+ ? fillLabel(labels['manage.callUsOn'], { phone })
158
+ : labelText(labels['manage.callUs']);
155
159
  }
156
160
  /** What the visitor is told went wrong — never the upstream's own words. */
157
161
  function errorSentence(labels, code, phone) {
@@ -159,13 +163,13 @@ function errorSentence(labels, code, phone) {
159
163
  case 'windowPassed':
160
164
  return fillLabel(labels['manage.error.windowPassed'], { ringUs: ringUs(labels, phone) });
161
165
  case 'slotTaken':
162
- return labels['manage.error.slotTaken'];
166
+ return labelText(labels['manage.error.slotTaken']);
163
167
  case 'notFound':
164
168
  return fillLabel(labels['manage.error.notFound'], { callUs: callUs(labels, phone) });
165
169
  case 'conflict':
166
170
  return fillLabel(labels['manage.error.conflict'], { ringUs: ringUs(labels, phone) });
167
171
  case 'invalidInput':
168
- return labels['manage.error.invalidInput'];
172
+ return labelText(labels['manage.error.invalidInput']);
169
173
  default:
170
174
  return fillLabel(labels['manage.error.unavailable'], { ringUs: ringUs(labels, phone) });
171
175
  }
@@ -200,7 +204,7 @@ export function ManageScreen({ labels, format, dayparts, booking, phone = null,
200
204
  ? null
201
205
  : format.stylistName(booking.resourceName) || null;
202
206
  const serviceLabel = booking.serviceName === UNKNOWN_SERVICE
203
- ? labels['manage.serviceFallback']
207
+ ? labelText(labels['manage.serviceFallback'])
204
208
  : booking.serviceName;
205
209
  // The LARGER window wins: the sentence promises both actions, and the earlier
206
210
  // of the two deadlines is the one true of both.
@@ -330,7 +334,7 @@ function ClosedBooking({ labels, format, status, bookingHref, phone, className,
330
334
  * contiguous, which the sub-line says.
331
335
  */
332
336
  function PartyChoice({ labels, format, bookedForName, phone, onJustThisOne, onBack, className, }) {
333
- const who = bookedForName ?? labels['manage.party.unnamed'];
337
+ const who = bookedForName ?? labelText(labels['manage.party.unnamed']);
334
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']] })] }));
335
339
  }
336
340
  /**
@@ -362,11 +366,12 @@ function ConfirmCancel({ labels, format, booking, reason, busy, onPickReason, on
362
366
  dateTime: format.clock.dateTime(booking.startTs),
363
367
  hours: booking.cancelWindowHours,
364
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) => {
365
- 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]);
366
371
  const picked = reason === option;
367
372
  return (_jsx(BookingButton, { variant: "outline", size: "sm", "aria-pressed": picked,
368
373
  // Tapping the chosen chip again clears it: the answer is
369
374
  // optional, so there has to be a way back to having given none.
370
- 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));
371
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'] })] })] }));
372
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 {
@@ -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.
@@ -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).
@@ -5,7 +5,7 @@ import { Field } from '../../primitives/field.js';
5
5
  import { Input } from '../../primitives/input.js';
6
6
  import { renderLabel } from '../internal/label-parts.js';
7
7
  import { BOOKING_INPUT_CLASS, BookingButton } from '../internal/ui.js';
8
- import { fillLabel } from '../labels.js';
8
+ import { fillLabel, labelText } from '../labels.js';
9
9
  import { slotClass } from '../slots.js';
10
10
  import { settle } from './action-result.js';
11
11
  export const DATA_CONTROLS_LABEL_KEYS = [
@@ -51,7 +51,7 @@ export function DataControls({ labels, onExport, onDelete, onSessionExpired, cla
51
51
  const [error, setError] = useState(null);
52
52
  const [exportError, setExportError] = useState(null);
53
53
  const [pending, startTransition] = useTransition();
54
- const word = labels['dataControls.confirmWord'];
54
+ const word = labelText(labels['dataControls.confirmWord']);
55
55
  const confirmId = `${id}-confirm`;
56
56
  const errorId = `${id}-error`;
57
57
  function failed(failure) {
@@ -59,7 +59,7 @@ export function DataControls({ labels, onExport, onDelete, onSessionExpired, cla
59
59
  onSessionExpired?.();
60
60
  return null;
61
61
  }
62
- return failure.message ?? labels['dataControls.unreachable'];
62
+ return failure.message ?? labelText(labels['dataControls.unreachable']);
63
63
  }
64
64
  function exportData() {
65
65
  setExportError(null);
@@ -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
@@ -6,7 +6,7 @@ import { Field } from '../../primitives/field.js';
6
6
  import { Input } from '../../primitives/input.js';
7
7
  import { Textarea } from '../../primitives/textarea.js';
8
8
  import { BOOKING_INPUT_CLASS, BOOKING_TEXTAREA_CLASS, BookingButton } from '../internal/ui.js';
9
- import { fillLabel } from '../labels.js';
9
+ import { fillLabel, labelText } from '../labels.js';
10
10
  import { slotClass } from '../slots.js';
11
11
  import { settle } from './action-result.js';
12
12
  import { AGE_CONFIRM_CARD_LABEL_KEYS, AgeConfirmCard, } from './age-confirm-card.js';
@@ -196,7 +196,7 @@ export function FamilyEditor({ labels, format, family, nouns, now = Date.now(),
196
196
  onSessionExpired?.();
197
197
  return null;
198
198
  }
199
- return failure.message ?? labels['familyEditor.unreachable'];
199
+ return failure.message ?? labelText(labels['familyEditor.unreachable']);
200
200
  }
201
201
  /** Never ask about this child again. */
202
202
  function rememberAgeAnswer(personId) {
@@ -352,7 +352,7 @@ export function FamilyEditor({ labels, format, family, nouns, now = Date.now(),
352
352
  adopt(result.family, row.key, isNew ? result.personId : null);
353
353
  setStatuses({
354
354
  [row.key]: result.fallback && gaveDetails
355
- ? { kind: 'saved', note: labels['familyEditor.savedNoDetails'] }
355
+ ? { kind: 'saved', note: labelText(labels['familyEditor.savedNoDetails']) }
356
356
  : { kind: 'saved' },
357
357
  });
358
358
  setListStatus(isNew ? { kind: 'saved' } : null);
@@ -371,13 +371,13 @@ export function FamilyEditor({ labels, format, family, nouns, now = Date.now(),
371
371
  showSaving({
372
372
  rows: rows.filter((current) => current.key !== row.key),
373
373
  statuses,
374
- listStatus: { kind: 'saved', note: labels['familyEditor.removed'] },
374
+ listStatus: { kind: 'saved', note: labelText(labels['familyEditor.removed']) },
375
375
  });
376
376
  const result = await settle(() => onRemovePerson(target(row)));
377
377
  if (result.ok) {
378
378
  adopt(result.family, row.key, null);
379
379
  setStatuses({});
380
- setListStatus({ kind: 'saved', note: labels['familyEditor.removed'] });
380
+ setListStatus({ kind: 'saved', note: labelText(labels['familyEditor.removed']) });
381
381
  return;
382
382
  }
383
383
  fail(result, null);
@@ -404,18 +404,18 @@ export function DefaultFamilyMemberCard({ id, row, years, months, offerDetails,
404
404
  // A stylist no longer on the roster keeps their option rather than the
405
405
  // select silently showing «none» for a preference the backend still holds.
406
406
  const stylistOptions = row.stylist !== '' && !stylists.some((stylist) => stylist.id === row.stylist)
407
- ? [...stylists, { id: row.stylist, name: labels['familyEditor.stylistFormer'] }]
407
+ ? [...stylists, { id: row.stylist, name: labelText(labels['familyEditor.stylistFormer']) }]
408
408
  : stylists;
409
409
  return (_jsxs("form", { onSubmit: (event) => {
410
410
  event.preventDefault();
411
411
  onSave();
412
- }, "aria-label": name || labels['familyEditor.newRow'], className: cn('space-y-3 rounded-lg border border-border bg-card px-5 py-4', className), children: [_jsxs("div", { className: "grid grid-cols-[1fr_7rem] gap-3", children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-name`, children: labels['familyEditor.name'] }), _jsx(Input, { id: `${id}-name`, value: row.name, maxLength: 60, "aria-invalid": nameMissing || undefined, "aria-describedby": partial ? statusId : undefined, onChange: (event) => onPatch({ name: event.target.value }), className: BOOKING_INPUT_CLASS })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-year`, children: labels['familyEditor.birthYear'] }), _jsxs("select", { id: `${id}-year`, value: row.birthYear, "aria-invalid": yearMissing || undefined, "aria-describedby": partial ? statusId : undefined, onChange: (event) => onPatch({ birthYear: event.target.value }), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['familyEditor.birthYearPlaceholder'] }), years.map((year) => (_jsx("option", { value: String(year), children: year }, year)))] })] })] }), offerDetails && (_jsx(DetailFields, { id: id, row: row, months: months, stylists: stylistOptions, labels: labels, onPatch: onPatch })), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx(BookingButton, { type: "submit", size: "sm", children: labels['familyEditor.save'] }), _jsx(BookingButton, { type: "button", variant: "ghost", size: "sm", "aria-label": name
412
+ }, "aria-label": name || labelText(labels['familyEditor.newRow']), className: cn('space-y-3 rounded-lg border border-border bg-card px-5 py-4', className), children: [_jsxs("div", { className: "grid grid-cols-[1fr_7rem] gap-3", children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-name`, children: labels['familyEditor.name'] }), _jsx(Input, { id: `${id}-name`, value: row.name, maxLength: 60, "aria-invalid": nameMissing || undefined, "aria-describedby": partial ? statusId : undefined, onChange: (event) => onPatch({ name: event.target.value }), className: BOOKING_INPUT_CLASS })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-year`, children: labels['familyEditor.birthYear'] }), _jsxs("select", { id: `${id}-year`, value: row.birthYear, "aria-invalid": yearMissing || undefined, "aria-describedby": partial ? statusId : undefined, onChange: (event) => onPatch({ birthYear: event.target.value }), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['familyEditor.birthYearPlaceholder'] }), years.map((year) => (_jsx("option", { value: String(year), children: year }, year)))] })] })] }), offerDetails && (_jsx(DetailFields, { id: id, row: row, months: months, stylists: stylistOptions, labels: labels, onPatch: onPatch })), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx(BookingButton, { type: "submit", size: "sm", children: labels['familyEditor.save'] }), _jsx(BookingButton, { type: "button", variant: "ghost", size: "sm", "aria-label": name
413
413
  ? fillLabel(labels['familyEditor.removeNamed'], { name })
414
- : labels['familyEditor.removeRow'], onClick: onRemove, children: labels['familyEditor.remove'] }), _jsx(RowStatusLine, { id: statusId, status: status, labels: labels })] })] }));
414
+ : labelText(labels['familyEditor.removeRow']), onClick: onRemove, children: labels['familyEditor.remove'] }), _jsx(RowStatusLine, { id: statusId, status: status, labels: labels })] })] }));
415
415
  }
416
416
  /** Month, preferred stylist and a note for the stylist — where the backend keeps them. */
417
417
  function DetailFields({ id, row, months, stylists, labels, onPatch, }) {
418
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-month`, children: labels['familyEditor.birthMonth'] }), _jsxs("select", { id: `${id}-month`, value: row.birthMonth, onChange: (event) => onPatch({ birthMonth: event.target.value }), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['familyEditor.birthMonthUnknown'] }), months.map((month, index) => (_jsx("option", { value: String(index + 1), children: month }, month)))] })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-stylist`, children: labels['familyEditor.stylist'] }), _jsxs("select", { id: `${id}-stylist`, value: row.stylist, onChange: (event) => onPatch({ stylist: event.target.value }), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['familyEditor.stylistNone'] }), stylists.map((stylist) => (_jsx("option", { value: stylist.id, children: stylist.name }, stylist.id)))] })] })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-notes`, children: labels['familyEditor.notes'] }), _jsx(Textarea, { id: `${id}-notes`, rows: 2, maxLength: NOTES_MAX, placeholder: labels['familyEditor.notesPlaceholder'], value: row.notes, onChange: (event) => onPatch({ notes: event.target.value }), className: BOOKING_TEXTAREA_CLASS })] })] }));
418
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-month`, children: labels['familyEditor.birthMonth'] }), _jsxs("select", { id: `${id}-month`, value: row.birthMonth, onChange: (event) => onPatch({ birthMonth: event.target.value }), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['familyEditor.birthMonthUnknown'] }), months.map((month, index) => (_jsx("option", { value: String(index + 1), children: month }, month)))] })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-stylist`, children: labels['familyEditor.stylist'] }), _jsxs("select", { id: `${id}-stylist`, value: row.stylist, onChange: (event) => onPatch({ stylist: event.target.value }), className: SELECT_CLASS, children: [_jsx("option", { value: "", children: labels['familyEditor.stylistNone'] }), stylists.map((stylist) => (_jsx("option", { value: stylist.id, children: stylist.name }, stylist.id)))] })] })] }), _jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: `${id}-notes`, children: labels['familyEditor.notes'] }), _jsx(Textarea, { id: `${id}-notes`, rows: 2, maxLength: NOTES_MAX, placeholder: labelText(labels['familyEditor.notesPlaceholder']), value: row.notes, onChange: (event) => onPatch({ notes: event.target.value }), className: BOOKING_TEXTAREA_CLASS })] })] }));
419
419
  }
420
420
  function RowStatusLine({ id, status, labels, }) {
421
421
  return (_jsxs("span", { "aria-live": "polite", className: "min-h-5 text-sm", children: [status?.kind === 'saved' && (_jsx("span", { id: id, className: "text-muted-foreground", children: status.note ?? labels['familyEditor.saved'] })), status?.kind === 'partial' && (_jsx(Field.Error, { id: id, children: labels['familyEditor.partial'] })), status?.kind === 'error' && _jsx(Field.Error, { id: id, children: status.message })] }));
@@ -1,7 +1,8 @@
1
+ import { type BookingLabel } from '../labels.js';
1
2
  import { type SlotClassNames } from '../slots.js';
2
3
  import type { SaveResult } from '../types.js';
3
4
  export declare const LOGOUT_BUTTON_LABEL_KEYS: readonly ["logout.button", "logout.unreachable"];
4
- export type LogoutButtonLabels = Record<(typeof LOGOUT_BUTTON_LABEL_KEYS)[number], string>;
5
+ export type LogoutButtonLabels = Record<(typeof LOGOUT_BUTTON_LABEL_KEYS)[number], BookingLabel>;
5
6
  /** `root` (the form), `button`, `error` (the live line under it). */
6
7
  export type LogoutButtonSlot = 'root' | 'button' | 'error';
7
8
  export interface LogoutButtonProps {
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState, useTransition } from 'react';
4
4
  import { BookingButton } from '../internal/ui.js';
5
+ import { labelText } from '../labels.js';
5
6
  import { slotClass } from '../slots.js';
6
7
  export const LOGOUT_BUTTON_LABEL_KEYS = ['logout.button', 'logout.unreachable'];
7
8
  /** «Log out», locked while the callback runs. */
@@ -20,7 +21,7 @@ export function LogoutButton({ labels, onLogout, classNames }) {
20
21
  result = { ok: false, message: '' };
21
22
  }
22
23
  if (!result.ok)
23
- setError(result.message || labels['logout.unreachable']);
24
+ setError(result.message || labelText(labels['logout.unreachable']));
24
25
  });
25
26
  }
26
27
  return (_jsxs("form", { onSubmit: logout, className: slotClass(classNames, 'root'), children: [_jsx(BookingButton, { type: "submit", variant: "outline", disabled: pending, className: slotClass(classNames, 'button'), children: labels['logout.button'] }), _jsx("p", { "aria-live": "polite", className: slotClass(classNames, 'error', 'mt-2 text-sm text-destructive'), children: error })] }));
@@ -1,4 +1,5 @@
1
1
  import { type ComponentType, type ReactNode } from 'react';
2
+ import { type BookingLabel } from '../labels.js';
2
3
  import { type SlotClassNames } from '../slots.js';
3
4
  export declare const PORTAL_SHELL_LABEL_KEYS: readonly ["portalShell.nav", "portalShell.navBar"];
4
5
  /**
@@ -6,7 +7,7 @@ export declare const PORTAL_SHELL_LABEL_KEYS: readonly ["portalShell.nav", "port
6
7
  * bottom bar. Only one of the two is displayed at a time; a pack may give both
7
8
  * the same name.
8
9
  */
9
- export type PortalShellLabels = Record<(typeof PORTAL_SHELL_LABEL_KEYS)[number], string>;
10
+ export type PortalShellLabels = Record<(typeof PORTAL_SHELL_LABEL_KEYS)[number], BookingLabel>;
10
11
  /** An icon the shell draws beside a tab label (e.g. a lucide icon). */
11
12
  export type PortalShellIcon = ComponentType<{
12
13
  className?: string;
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { 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
  export const PORTAL_SHELL_LABEL_KEYS = ['portalShell.nav', 'portalShell.navBar'];
7
8
  /**
@@ -26,12 +27,12 @@ export function PortalShell({ labels, tabs, tab: tabProp, defaultTab, onTabChang
26
27
  setUncontrolled(next);
27
28
  onTabChange?.(next);
28
29
  }
29
- return (_jsxs("div", { className: slotClass(classNames, 'root', 'space-y-8'), children: [header, _jsxs("div", { className: "gap-10 lg:grid lg:grid-cols-[15rem_minmax(0,1fr)]", children: [_jsx("aside", { className: slotClass(classNames, 'rail', 'hidden lg:block'), children: _jsxs("div", { className: "sticky top-8 space-y-4", children: [account, _jsx("nav", { "aria-label": labels['portalShell.nav'], children: _jsx("ul", { className: "space-y-1", children: tabs.map(({ id, label, icon: Icon }) => (_jsx("li", { children: _jsxs("button", { type: "button", "aria-current": tab === id ? 'page' : undefined, onClick: () => select(id), className: cn(slotClass(classNames, 'railItem', 'flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left text-sm font-semibold transition-colors', tab === id
30
+ return (_jsxs("div", { className: slotClass(classNames, 'root', 'space-y-8'), children: [header, _jsxs("div", { className: "gap-10 lg:grid lg:grid-cols-[15rem_minmax(0,1fr)]", children: [_jsx("aside", { className: slotClass(classNames, 'rail', 'hidden lg:block'), children: _jsxs("div", { className: "sticky top-8 space-y-4", children: [account, _jsx("nav", { "aria-label": labelText(labels['portalShell.nav']), children: _jsx("ul", { className: "space-y-1", children: tabs.map(({ id, label, icon: Icon }) => (_jsx("li", { children: _jsxs("button", { type: "button", "aria-current": tab === id ? 'page' : undefined, onClick: () => select(id), className: cn(slotClass(classNames, 'railItem', 'flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left text-sm font-semibold transition-colors', tab === id
30
31
  ? 'bg-foreground text-background'
31
32
  : 'text-foreground hover:bg-muted'), tab === id && classNames?.railItemActive), children: [Icon && _jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0" }), label] }) }, id))) }) }), logout] }) }), _jsx("div", { className: "pb-28 lg:pb-0", children: tabs.map(({ id, label, content }) => (_jsxs("section", { hidden: tab !== id, "aria-label": label,
32
33
  // `space-y` on the section rather than a wrapper, so a hidden one
33
34
  // contributes no margin.
34
- className: slotClass(classNames, 'section', 'space-y-10'), children: [content, id === logoutIn && logout && _jsx("div", { className: "lg:hidden", children: logout })] }, id))) })] }), _jsx("nav", { "aria-label": labels['portalShell.navBar'], className: slotClass(classNames, 'bar', 'fixed inset-x-0 bottom-0 z-40 border-t border-border bg-background/95 backdrop-blur-md lg:hidden'), children: _jsx("ul", { className: "mx-auto flex max-w-lg", children: tabs.map(({ id, label, short, icon, shortIcon }) => {
35
+ className: slotClass(classNames, 'section', 'space-y-10'), children: [content, id === logoutIn && logout && _jsx("div", { className: "lg:hidden", children: logout })] }, id))) })] }), _jsx("nav", { "aria-label": labelText(labels['portalShell.navBar']), className: slotClass(classNames, 'bar', 'fixed inset-x-0 bottom-0 z-40 border-t border-border bg-background/95 backdrop-blur-md lg:hidden'), children: _jsx("ul", { className: "mx-auto flex max-w-lg", children: tabs.map(({ id, label, short, icon, shortIcon }) => {
35
36
  const ShortIcon = shortIcon ?? icon;
36
37
  return (_jsx("li", { className: "flex-1", children: _jsxs("button", { type: "button", "aria-current": tab === id ? 'page' : undefined, onClick: () => select(id), className: cn(slotClass(classNames, 'barItem', 'flex w-full flex-col items-center gap-1 px-2 py-3 text-[0.7rem] font-bold transition-colors', tab === id ? 'text-primary' : 'text-muted-foreground'), tab === id && classNames?.barItemActive), children: [ShortIcon && _jsx(ShortIcon, { "aria-hidden": "true", className: "size-5" }), short ?? label] }) }, id));
37
38
  }) }) })] }));
@@ -1,9 +1,10 @@
1
1
  import type { 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
  export declare const PORTAL_UNREACHABLE_LABEL_KEYS: readonly ["portalUnreachable.heading", "portalUnreachable.body", "portalUnreachable.retry", "portalUnreachable.call"];
5
6
  /** `portalUnreachable.call` takes `{phone}`. */
6
- export type PortalUnreachableLabels = Record<(typeof PORTAL_UNREACHABLE_LABEL_KEYS)[number], string>;
7
+ export type PortalUnreachableLabels = Record<(typeof PORTAL_UNREACHABLE_LABEL_KEYS)[number], BookingLabel>;
7
8
  /** - `root`: the section. - `heading`. - `actions`: the row of links. - `link`: each link. */
8
9
  export type PortalUnreachableSlot = 'root' | 'heading' | 'actions' | 'link';
9
10
  export interface PortalUnreachableProps {
@@ -1,9 +1,10 @@
1
+ import { type BookingLabel } from '../labels.js';
1
2
  import { type SlotClassNames } from '../slots.js';
2
3
  import type { PortalProfileDto } from '../types.js';
3
4
  import { type PortalActionFailure } from './action-result.js';
4
5
  export declare const PROFILE_FORM_LABEL_KEYS: readonly ["profileForm.heading", "profileForm.firstName", "profileForm.lastName", "profileForm.phone", "profileForm.email", "profileForm.emailHelp", "profileForm.save", "profileForm.saved", "profileForm.restored", "profileForm.marketing", "profileForm.unreachable"];
5
6
  /** `marketing` is the consent sentence and usually names the business. */
6
- export type ProfileFormLabels = Record<(typeof PROFILE_FORM_LABEL_KEYS)[number], string>;
7
+ export type ProfileFormLabels = Record<(typeof PROFILE_FORM_LABEL_KEYS)[number], BookingLabel>;
7
8
  /**
8
9
  * `root` (the section), `heading`, `form`, `status` (the save notice's live
9
10
  * region), `save` (the submit button), `consent` (the marketing box's field).
@@ -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 { BOOKING_CHECKBOX_CLASS, BOOKING_INPUT_CLASS, BookingButton } from '../internal/ui.js';
8
+ import { labelText } from '../labels.js';
8
9
  import { slotClass } from '../slots.js';
9
10
  import { settle } from './action-result.js';
10
11
  export const PROFILE_FORM_LABEL_KEYS = [
@@ -103,7 +104,7 @@ export function ProfileForm({ labels, profile, onSave, onConsentChange, onSessio
103
104
  }
104
105
  setStatus({
105
106
  kind: 'error',
106
- message: result.message ?? labels['profileForm.unreachable'],
107
+ message: result.message ?? labelText(labels['profileForm.unreachable']),
107
108
  field: 'field' in result && result.field === 'phone' ? 'phone' : null,
108
109
  });
109
110
  });
@@ -124,7 +125,7 @@ export function ProfileForm({ labels, profile, onSave, onConsentChange, onSessio
124
125
  onSessionExpired?.();
125
126
  return;
126
127
  }
127
- setConsentError(labels['profileForm.unreachable']);
128
+ setConsentError(labelText(labels['profileForm.unreachable']));
128
129
  });
129
130
  }
130
131
  const view = shown.status;
@@ -1,8 +1,9 @@
1
1
  import type { ComponentType } from 'react';
2
+ import { type BookingLabel } from '../labels.js';
2
3
  import { type SlotClassNames } from '../slots.js';
3
4
  import type { RebookSuggestion } from '../types.js';
4
5
  export declare const REBOOK_CARDS_LABEL_KEYS: readonly ["rebook.heading", "rebook.empty", "rebook.serviceFallback", "rebook.anyStylist", "rebook.cta"];
5
- export type RebookCardsLabels = Record<(typeof REBOOK_CARDS_LABEL_KEYS)[number], string>;
6
+ export type RebookCardsLabels = Record<(typeof REBOOK_CARDS_LABEL_KEYS)[number], BookingLabel>;
6
7
  /** - `root`: the section. - `heading`. - `empty`. - `list`. - `card`. - `link`: the book link. */
7
8
  export type RebookCardsSlot = 'root' | 'heading' | 'empty' | 'list' | 'card' | 'link';
8
9
  export interface RebookCardProps {