@medalsocial/meda 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +35 -1
  2. package/dist/booking/add-child-sheet.d.ts +2 -1
  3. package/dist/booking/add-child-sheet.js +2 -1
  4. package/dist/booking/booking-skeleton.d.ts +2 -1
  5. package/dist/booking/confirmation.d.ts +2 -1
  6. package/dist/booking/confirmation.js +7 -6
  7. package/dist/booking/details-screen.d.ts +5 -1
  8. package/dist/booking/details-screen.js +13 -8
  9. package/dist/booking/index.d.ts +2 -2
  10. package/dist/booking/index.js +2 -2
  11. package/dist/booking/internal/label-parts.d.ts +36 -21
  12. package/dist/booking/internal/label-parts.js +71 -43
  13. package/dist/booking/internal/service-multi.d.ts +55 -0
  14. package/dist/booking/internal/service-multi.js +83 -0
  15. package/dist/booking/internal/visit.d.ts +13 -0
  16. package/dist/booking/internal/visit.js +10 -0
  17. package/dist/booking/label-keys.d.ts +13 -5
  18. package/dist/booking/label-keys.js +2 -1
  19. package/dist/booking/labels.d.ts +37 -8
  20. package/dist/booking/labels.js +45 -8
  21. package/dist/booking/login-panel.d.ts +3 -2
  22. package/dist/booking/login-panel.js +3 -2
  23. package/dist/booking/login-sheet.d.ts +2 -1
  24. package/dist/booking/login-sheet.js +2 -1
  25. package/dist/booking/manage-screen.d.ts +2 -1
  26. package/dist/booking/manage-screen.js +16 -11
  27. package/dist/booking/otp-slots.d.ts +2 -1
  28. package/dist/booking/otp-slots.js +2 -1
  29. package/dist/booking/portal/age-confirm-card.d.ts +2 -1
  30. package/dist/booking/portal/child-cards.d.ts +2 -1
  31. package/dist/booking/portal/data-controls.d.ts +2 -1
  32. package/dist/booking/portal/data-controls.js +3 -3
  33. package/dist/booking/portal/family-editor.d.ts +2 -1
  34. package/dist/booking/portal/family-editor.js +9 -9
  35. package/dist/booking/portal/logout-button.d.ts +2 -1
  36. package/dist/booking/portal/logout-button.js +2 -1
  37. package/dist/booking/portal/portal-shell.d.ts +2 -1
  38. package/dist/booking/portal/portal-shell.js +3 -2
  39. package/dist/booking/portal/portal-unreachable.d.ts +2 -1
  40. package/dist/booking/portal/profile-form.d.ts +2 -1
  41. package/dist/booking/portal/profile-form.js +3 -2
  42. package/dist/booking/portal/rebook-cards.d.ts +2 -1
  43. package/dist/booking/portal/rebook-cards.js +3 -2
  44. package/dist/booking/portal/upcoming-bookings.d.ts +2 -1
  45. package/dist/booking/portal/upcoming-bookings.js +3 -3
  46. package/dist/booking/portal/vipps-link-row.d.ts +2 -1
  47. package/dist/booking/portal/vipps-link-row.js +7 -6
  48. package/dist/booking/portal/visit-history.d.ts +2 -1
  49. package/dist/booking/service-screen.d.ts +78 -3
  50. package/dist/booking/service-screen.js +131 -46
  51. package/dist/booking/stylist-screen.d.ts +2 -1
  52. package/dist/booking/stylist-screen.js +7 -6
  53. package/dist/booking/summary-bar.d.ts +2 -1
  54. package/dist/booking/time-screen.d.ts +2 -1
  55. package/dist/booking/time-screen.js +42 -19
  56. package/dist/booking/types.d.ts +14 -1
  57. package/dist/booking/vipps-button.d.ts +2 -1
  58. package/dist/booking/vipps-button.js +2 -1
  59. package/dist/booking/who-screen.d.ts +2 -1
  60. package/dist/booking/who-screen.js +4 -3
  61. package/package.json +1 -1
  62. package/skills/components/SKILL.md +4 -0
@@ -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 {
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { labelText } from '../labels.js';
3
4
  import { slotClass } from '../slots.js';
4
5
  export const REBOOK_CARDS_LABEL_KEYS = [
5
6
  'rebook.heading',
@@ -26,9 +27,9 @@ export function RebookCards({ suggestions, hrefFor, onRebook, labels, headingId
26
27
  /** One suggestion (an `<li>`): what · who for · with whom, and the book link. */
27
28
  export function DefaultRebookCard({ suggestion, href, onRebook, labels, classNames, }) {
28
29
  return (_jsxs("li", { className: slotClass(classNames, 'card', 'space-y-2 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsx("p", { className: "font-semibold", children: [
29
- suggestion.serviceName ?? labels['rebook.serviceFallback'],
30
+ suggestion.serviceName ?? labelText(labels['rebook.serviceFallback']),
30
31
  suggestion.bookedForName,
31
- suggestion.resourceName ?? labels['rebook.anyStylist'],
32
+ suggestion.resourceName ?? labelText(labels['rebook.anyStylist']),
32
33
  ]
33
34
  .filter(Boolean)
34
35
  .join(SEPARATOR) }), _jsx("a", { href: href, onClick: onRebook ? () => onRebook(suggestion) : undefined, className: slotClass(classNames, 'link', 'text-sm font-semibold text-primary underline underline-offset-4'), children: labels['rebook.cta'] })] }));
@@ -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 { PortalBookingDto } from '../types.js';
5
6
  export declare const UPCOMING_BOOKINGS_LABEL_KEYS: readonly ["upcoming.heading", "upcoming.empty", "upcoming.book", "upcoming.next", "upcoming.today", "upcoming.tomorrow", "upcoming.inDays", "upcoming.when", "upcoming.serviceFallback", "upcoming.anyStylist", "upcoming.manage", "upcoming.noManage", "upcoming.noManageWithPhone", "upcoming.call"];
@@ -9,7 +10,7 @@ export declare const UPCOMING_BOOKINGS_LABEL_KEYS: readonly ["upcoming.heading",
9
10
  * `upcoming.noManageWithPhone` `{call}` (where the `upcoming.call` link goes),
10
11
  * `upcoming.call` `{phone}`.
11
12
  */
12
- export type UpcomingBookingsLabels = Record<(typeof UPCOMING_BOOKINGS_LABEL_KEYS)[number], string>;
13
+ export type UpcomingBookingsLabels = Record<(typeof UPCOMING_BOOKINGS_LABEL_KEYS)[number], BookingLabel>;
13
14
  /**
14
15
  * - `root`: the section. - `heading`. - `empty`: the no-bookings box.
15
16
  * - `list`. - `card`: a plain card; `cardHero`: the first (next) one.
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { cn } from '../../lib/utils.js';
4
4
  import { joinLabelParts, labelParts, renderLabel } from '../internal/label-parts.js';
5
5
  import { bookingButtonClass } from '../internal/ui.js';
6
- import { fillLabel } from '../labels.js';
6
+ import { fillLabel, labelText } from '../labels.js';
7
7
  import { slotClass } from '../slots.js';
8
8
  export const UPCOMING_BOOKINGS_LABEL_KEYS = [
9
9
  'upcoming.heading',
@@ -29,9 +29,9 @@ const SEPARATOR = ' · ';
29
29
  function howFarOff(startTs, now, labels, format) {
30
30
  const days = format.clock.daysBetween(now, startTs);
31
31
  if (days <= 0)
32
- return labels['upcoming.today'];
32
+ return labelText(labels['upcoming.today']);
33
33
  if (days === 1)
34
- return labels['upcoming.tomorrow'];
34
+ return labelText(labels['upcoming.tomorrow']);
35
35
  return fillLabel(labels['upcoming.inDays'], { count: days });
36
36
  }
37
37
  /** What is booked and who for. */
@@ -1,6 +1,7 @@
1
+ import { type BookingLabel } from '../labels.js';
1
2
  import { type SlotClassNames } from '../slots.js';
2
3
  export declare const VIPPS_LINK_ROW_LABEL_KEYS: readonly ["vippsLink.heading", "vippsLink.linked", "vippsLink.pitch", "vippsLink.button", "vippsLink.success", "vippsLink.conflict", "vippsLink.failed", "vippsLink.unavailable", "vippsLink.throttled"];
3
- export type VippsLinkRowLabels = Record<(typeof VIPPS_LINK_ROW_LABEL_KEYS)[number], string>;
4
+ export type VippsLinkRowLabels = Record<(typeof VIPPS_LINK_ROW_LABEL_KEYS)[number], BookingLabel>;
4
5
  /** `root` (the section), `card` (the bordered row), `heading`, `button`, `notice` (the live line). */
5
6
  export type VippsLinkRowSlot = 'root' | 'card' | 'heading' | 'button' | 'notice';
6
7
  /** The outcome of a link attempt that just returned, as the server recorded it. */
@@ -4,6 +4,7 @@ import { useActionState, useEffect, useId, useRef } from 'react';
4
4
  import { useFormStatus } from 'react-dom';
5
5
  import { cn } from '../../lib/utils.js';
6
6
  import { BookingButton } from '../internal/ui.js';
7
+ import { labelText } from '../labels.js';
7
8
  import { slotClass } from '../slots.js';
8
9
  export const VIPPS_LINK_ROW_LABEL_KEYS = [
9
10
  'vippsLink.heading',
@@ -23,17 +24,17 @@ function LinkButton({ label, className }) {
23
24
  /** The sentence under the row: a failed start first, else what the return said. */
24
25
  function noticeFor(state, isLinked, flash, labels, announceSuccess) {
25
26
  if (state?.reason === 'unavailable')
26
- return labels['vippsLink.unavailable'];
27
+ return labelText(labels['vippsLink.unavailable']);
27
28
  if (state?.reason === 'throttled')
28
- return labels['vippsLink.throttled'];
29
+ return labelText(labels['vippsLink.throttled']);
29
30
  if (flash === 'linked' && announceSuccess)
30
- return labels['vippsLink.success'];
31
+ return labelText(labels['vippsLink.success']);
31
32
  if (isLinked)
32
33
  return null;
33
34
  if (flash === 'link_conflict')
34
- return labels['vippsLink.conflict'];
35
+ return labelText(labels['vippsLink.conflict']);
35
36
  if (flash === 'link_failed')
36
- return labels['vippsLink.failed'];
37
+ return labelText(labels['vippsLink.failed']);
37
38
  return null;
38
39
  }
39
40
  /**
@@ -76,5 +77,5 @@ export function VippsLinkRow({ labels, linked, flash, onStart, onSessionExpired,
76
77
  const sentence = noticeFor(state, linked, flash, labels, announceSuccess);
77
78
  const headingId = `${id}-heading`;
78
79
  const noticeId = `${id}-notice`;
79
- return (_jsxs("section", { "aria-labelledby": headingId, className: slotClass(classNames, 'root', 'space-y-2'), children: [_jsxs("div", { className: slotClass(classNames, 'card', 'flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsxs("div", { className: "min-w-0", children: [_jsx("h2", { id: headingId, className: slotClass(classNames, 'heading', 'font-semibold'), children: labels['vippsLink.heading'] }), _jsx("p", { className: "text-sm text-muted-foreground", children: linked ? labels['vippsLink.linked'] : labels['vippsLink.pitch'] })] }), !linked && (_jsx("form", { action: formAction, "aria-describedby": sentence ? noticeId : undefined, children: _jsx(LinkButton, { label: labels['vippsLink.button'], className: classNames?.button }) }))] }), _jsx("div", { "aria-live": "polite", className: slotClass(classNames, 'notice', 'min-h-5'), children: sentence && (_jsx("p", { id: noticeId, className: "text-sm text-muted-foreground", children: sentence })) })] }));
80
+ return (_jsxs("section", { "aria-labelledby": headingId, className: slotClass(classNames, 'root', 'space-y-2'), children: [_jsxs("div", { className: slotClass(classNames, 'card', 'flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card px-5 py-4'), children: [_jsxs("div", { className: "min-w-0", children: [_jsx("h2", { id: headingId, className: slotClass(classNames, 'heading', 'font-semibold'), children: labels['vippsLink.heading'] }), _jsx("p", { className: "text-sm text-muted-foreground", children: linked ? labels['vippsLink.linked'] : labels['vippsLink.pitch'] })] }), !linked && (_jsx("form", { action: formAction, "aria-describedby": sentence ? noticeId : undefined, children: _jsx(LinkButton, { label: labelText(labels['vippsLink.button']), className: classNames?.button }) }))] }), _jsx("div", { "aria-live": "polite", className: slotClass(classNames, 'notice', 'min-h-5'), children: sentence && (_jsx("p", { id: noticeId, className: "text-sm text-muted-foreground", children: sentence })) })] }));
80
81
  }
@@ -1,10 +1,11 @@
1
1
  import { type ComponentType } from 'react';
2
2
  import type { BookingClock, BookingFormat } from '../format.js';
3
+ import type { BookingLabel } from '../labels.js';
3
4
  import { type SlotClassNames } from '../slots.js';
4
5
  import type { PortalBookingDto } from '../types.js';
5
6
  export declare const VISIT_HISTORY_LABEL_KEYS: readonly ["history.heading", "history.empty", "history.serviceFallback", "history.count.one", "history.count.other", "history.unknownAmount"];
6
7
  /** `history.count.*` take `{count}` and `{year}`. */
7
- export type VisitHistoryLabels = Record<(typeof VISIT_HISTORY_LABEL_KEYS)[number], string>;
8
+ export type VisitHistoryLabels = Record<(typeof VISIT_HISTORY_LABEL_KEYS)[number], BookingLabel>;
8
9
  /**
9
10
  * - `root`: the section. - `heading`. - `empty`: the no-visits box.
10
11
  * - `yearChip` / `yearChipSelected`: the year switcher's buttons.
@@ -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 { BookingServiceDto } from './types.js';
5
6
  /**
@@ -8,9 +9,32 @@ import type { BookingServiceDto } from './types.js';
8
9
  * Presentational. A tap raises `onPick` and the caller's machine decides what
9
10
  * that means; nothing here knows which step comes next or whether the basket
10
11
  * already holds a child.
12
+ *
13
+ * With a `selection`, the step is multi-select instead: one person may have
14
+ * several services as ONE visit, the cards tick rather than answer, and a
15
+ * sticky bar carries the running total and the way on.
11
16
  */
12
17
  export declare const SERVICE_SCREEN_LABEL_KEYS: readonly ["service.heading", "service.categoriesLegend", "service.sameAsLast", "service.chooseOther", "service.duration", "service.phoneOnly", "service.phoneOnlyLinkLabel", "service.ageDivider.named", "service.ageDivider.unnamed", "service.party.listLabel", "service.party.adultAlone", "service.party.nothingFits", "service.party.remove"];
13
- export type ServiceScreenLabels = Record<(typeof SERVICE_SCREEN_LABEL_KEYS)[number], string>;
18
+ /**
19
+ * The keys only the multi-select step reads (`ServiceScreenProps.selection`).
20
+ * Optional in `ServiceScreenLabels`, so a pack written before they existed
21
+ * still type-checks; a `selection` carries them all, required, in
22
+ * `ServiceSelection.labels` — a multi-select step cannot compile without them.
23
+ *
24
+ * - `service.multiHint` — under the heading: you may tick more than one;
25
+ * - `service.total` — the sticky bar's running total, `{minutes}` and `{price}`;
26
+ * - `service.chooseFirst` — the sticky bar's line while someone has nothing ticked;
27
+ * - `service.continue` — the sticky bar's button;
28
+ * - `service.party.nothingBookable` — a family member with nothing on their
29
+ * menu, `{label}`: the business takes nothing for them online (the party's
30
+ * size is no longer the reason, as `service.party.nothingFits` says);
31
+ * - `service.tabDone` — read after a family member's tab name once they have
32
+ * something ticked (the visible tick, in words): «Theo, ferdig».
33
+ */
34
+ export declare const SERVICE_SCREEN_MULTI_LABEL_KEYS: readonly ["service.multiHint", "service.total", "service.chooseFirst", "service.continue", "service.party.nothingBookable", "service.tabDone"];
35
+ /** The multi-select step's labels, every one required (`ServiceSelection.labels`). */
36
+ export type ServiceMultiLabels = Record<(typeof SERVICE_SCREEN_MULTI_LABEL_KEYS)[number], BookingLabel>;
37
+ export type ServiceScreenLabels = Record<(typeof SERVICE_SCREEN_LABEL_KEYS)[number], BookingLabel> & Partial<ServiceMultiLabels>;
14
38
  /**
15
39
  * - `root` — the `<section>`
16
40
  * - `heading` — the `<h2>`
@@ -18,8 +42,10 @@ export type ServiceScreenLabels = Record<(typeof SERVICE_SCREEN_LABEL_KEYS)[numb
18
42
  * - `groupHeading` — a category's (or a party member's) `<h3>`
19
43
  * - `card` / `cardSelected` — a service card / a pressed one (passed to `ServiceCard`)
20
44
  * - `price` — a card's price (passed to `ServiceCard`, and the «same as last time» card's)
45
+ * - `tab` / `tabSelected` — a person's tab in a multi-select family / the open one
46
+ * - `bar` — the multi-select step's sticky total bar
21
47
  */
22
- export type ServiceScreenSlot = 'root' | 'heading' | 'pill' | 'pillSelected' | 'groupHeading' | 'card' | 'cardSelected' | 'price';
48
+ export type ServiceScreenSlot = 'tab' | 'tabSelected' | 'bar' | 'root' | 'heading' | 'pill' | 'pillSelected' | 'groupHeading' | 'card' | 'cardSelected' | 'price';
23
49
  /** One service group, in display order. */
24
50
  export interface ServiceScreenCategory {
25
51
  key: string;
@@ -50,6 +76,13 @@ export interface ServiceCardProps {
50
76
  */
51
77
  kind: 'book' | 'choice' | 'phone';
52
78
  selected: boolean;
79
+ /**
80
+ * `choice` only: the card is one of several the person may tick (a
81
+ * checkbox, `selected` = ticked), not their one answer (a pressed button).
82
+ * Set on the multi-select step (`ServiceScreenProps.selection`).
83
+ */
84
+ multiple?: boolean;
85
+ /** Raised on a tap. On a `multiple` card it toggles; nothing advances. */
53
86
  onPick: () => void;
54
87
  /** The number for `phone` cards; `null` renders the sentence without a link. */
55
88
  phone: string | null;
@@ -60,6 +93,40 @@ export interface ServiceCardProps {
60
93
  /** Merged onto the price (the `price` slot). */
61
94
  priceClassName?: string;
62
95
  }
96
+ /**
97
+ * The multi-select step: each person ticks one or more services, done back to
98
+ * back as one visit. The caller's machine owns the lists; the screen only
99
+ * reports taps.
100
+ */
101
+ export interface ServiceSelection {
102
+ /** Each person's services so far, in order (index = party person; a lone person is index 0). */
103
+ lists: ReadonlyArray<ReadonlyArray<{
104
+ id: string;
105
+ }>>;
106
+ /** A tick or an untick on the person whose tab is open (0 for a lone person). Never advances. */
107
+ onToggle: (personIndex: number, service: BookingServiceDto) => void;
108
+ /** «Next» in the sticky bar. Called whether or not `canContinue`: the caller decides. */
109
+ onContinue: () => void;
110
+ /** The running total for the whole booking, or `null` when nothing is chosen. */
111
+ total: {
112
+ minutes: number;
113
+ priceOre: number;
114
+ } | null;
115
+ /**
116
+ * Whether every person has at least one service. «Next» stays enabled
117
+ * either way (a disabled button explains nothing); while `false` the bar
118
+ * shows `service.chooseFirst` instead of the total.
119
+ */
120
+ canContinue: boolean;
121
+ /** A refusal to say politely (e.g. too many services), announced as a live region. */
122
+ notice?: string | null;
123
+ /**
124
+ * The multi-select step's labels (`SERVICE_SCREEN_MULTI_LABEL_KEYS`), all
125
+ * required: a pack written before multi-select cannot reach this step and
126
+ * draw an unnamed «Next». A pack that defines them can be passed whole.
127
+ */
128
+ labels: ServiceMultiLabels;
129
+ }
63
130
  export interface ServiceScreenComponents {
64
131
  ServiceCard?: ComponentType<ServiceCardProps>;
65
132
  }
@@ -125,6 +192,14 @@ export interface ServiceScreenProps {
125
192
  possessive?: (name: string) => string;
126
193
  classNames?: SlotClassNames<ServiceScreenSlot>;
127
194
  components?: ServiceScreenComponents;
195
+ /**
196
+ * Switches the step to multi-select (see `ServiceSelection`): bookable cards
197
+ * become checkboxes, a family gets one tab per person, and a sticky bar
198
+ * shows the total and «Next». `onPick` / `party.onPickFor` then serve only
199
+ * the one-tap «same as last time». Its labels come in `selection.labels`.
200
+ * Absent: the one-tap step, unchanged.
201
+ */
202
+ selection?: ServiceSelection;
128
203
  }
129
204
  export declare function ServiceScreen(props: ServiceScreenProps): import("react").JSX.Element;
130
205
  /**
@@ -137,4 +212,4 @@ export declare function ServiceScreen(props: ServiceScreenProps): import("react"
137
212
  * online, and a tap target that accepts the tap and does nothing is the dead
138
213
  * end the phone number replaces. The card stays so the catalogue is whole.
139
214
  */
140
- export declare function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, priceClassName, }: ServiceCardProps): import("react").JSX.Element;
215
+ export declare function DefaultServiceCard({ service, kind, selected, multiple, onPick, phone, format, labels, className, selectedClassName, priceClassName, }: ServiceCardProps): import("react").JSX.Element;