@medalsocial/meda 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +35 -1
  2. package/dist/booking/add-child-sheet.d.ts +2 -1
  3. package/dist/booking/add-child-sheet.js +2 -1
  4. package/dist/booking/booking-skeleton.d.ts +2 -1
  5. package/dist/booking/confirmation.d.ts +2 -1
  6. package/dist/booking/confirmation.js +11 -11
  7. package/dist/booking/details-screen.d.ts +2 -1
  8. package/dist/booking/details-screen.js +13 -10
  9. package/dist/booking/index.d.ts +2 -1
  10. package/dist/booking/index.js +2 -1
  11. package/dist/booking/internal/label-parts.d.ts +39 -4
  12. package/dist/booking/internal/label-parts.js +78 -12
  13. package/dist/booking/label-keys.d.ts +2 -1
  14. package/dist/booking/labels.d.ts +37 -8
  15. package/dist/booking/labels.js +45 -8
  16. package/dist/booking/login-panel.d.ts +3 -2
  17. package/dist/booking/login-panel.js +8 -7
  18. package/dist/booking/login-sheet.d.ts +2 -1
  19. package/dist/booking/login-sheet.js +2 -1
  20. package/dist/booking/manage-screen.d.ts +2 -1
  21. package/dist/booking/manage-screen.js +30 -22
  22. package/dist/booking/otp-slots.d.ts +2 -1
  23. package/dist/booking/otp-slots.js +2 -1
  24. package/dist/booking/portal/age-confirm-card.d.ts +2 -1
  25. package/dist/booking/portal/age-confirm-card.js +3 -3
  26. package/dist/booking/portal/child-cards.d.ts +2 -1
  27. package/dist/booking/portal/child-cards.js +5 -5
  28. package/dist/booking/portal/data-controls.d.ts +2 -1
  29. package/dist/booking/portal/data-controls.js +5 -4
  30. package/dist/booking/portal/family-editor.d.ts +2 -1
  31. package/dist/booking/portal/family-editor.js +9 -9
  32. package/dist/booking/portal/logout-button.d.ts +2 -1
  33. package/dist/booking/portal/logout-button.js +2 -1
  34. package/dist/booking/portal/portal-shell.d.ts +2 -1
  35. package/dist/booking/portal/portal-shell.js +3 -2
  36. package/dist/booking/portal/portal-unreachable.d.ts +2 -1
  37. package/dist/booking/portal/portal-unreachable.js +2 -2
  38. package/dist/booking/portal/profile-form.d.ts +2 -1
  39. package/dist/booking/portal/profile-form.js +3 -2
  40. package/dist/booking/portal/rebook-cards.d.ts +2 -1
  41. package/dist/booking/portal/rebook-cards.js +3 -2
  42. package/dist/booking/portal/upcoming-bookings.d.ts +2 -1
  43. package/dist/booking/portal/upcoming-bookings.js +10 -14
  44. package/dist/booking/portal/vipps-link-row.d.ts +2 -1
  45. package/dist/booking/portal/vipps-link-row.js +7 -6
  46. package/dist/booking/portal/visit-history.d.ts +2 -1
  47. package/dist/booking/portal/visit-history.js +2 -2
  48. package/dist/booking/service-screen.d.ts +7 -3
  49. package/dist/booking/service-screen.js +9 -7
  50. package/dist/booking/stylist-screen.d.ts +15 -3
  51. package/dist/booking/stylist-screen.js +21 -17
  52. package/dist/booking/summary-bar.d.ts +2 -1
  53. package/dist/booking/time-screen.d.ts +2 -1
  54. package/dist/booking/time-screen.js +16 -15
  55. package/dist/booking/vipps-button.d.ts +2 -1
  56. package/dist/booking/vipps-button.js +2 -1
  57. package/dist/booking/who-screen.d.ts +2 -1
  58. package/dist/booking/who-screen.js +7 -6
  59. package/package.json +8 -8
  60. package/skills/components/SKILL.md +4 -0
@@ -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,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { fillLabel } from '../labels.js';
2
+ import { renderLabel } from '../internal/label-parts.js';
3
3
  import { slotClass } from '../slots.js';
4
4
  export const PORTAL_UNREACHABLE_LABEL_KEYS = [
5
5
  'portalUnreachable.heading',
@@ -15,5 +15,5 @@ export const PORTAL_UNREACHABLE_LABEL_KEYS = [
15
15
  */
16
16
  export function PortalUnreachable({ labels, format, phone, retryHref, logout, headingId = 'portal-unreachable-heading', classNames, }) {
17
17
  const link = slotClass(classNames, 'link', 'font-semibold text-primary underline underline-offset-4');
18
- return (_jsxs("section", { "aria-labelledby": headingId, className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h1", { id: headingId, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold md:text-3xl'), children: labels['portalUnreachable.heading'] }), _jsx("p", { className: "text-muted-foreground", children: labels['portalUnreachable.body'] }), _jsxs("div", { className: slotClass(classNames, 'actions', 'flex flex-wrap items-center gap-4'), children: [_jsx("a", { href: retryHref, className: link, children: labels['portalUnreachable.retry'] }), phone && (_jsx("a", { href: format.telHref(phone), className: link, children: fillLabel(labels['portalUnreachable.call'], { phone }) })), logout] })] }));
18
+ return (_jsxs("section", { "aria-labelledby": headingId, className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h1", { id: headingId, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold md:text-3xl'), children: labels['portalUnreachable.heading'] }), _jsx("p", { className: "text-muted-foreground", children: labels['portalUnreachable.body'] }), _jsxs("div", { className: slotClass(classNames, 'actions', 'flex flex-wrap items-center gap-4'), children: [_jsx("a", { href: retryHref, className: link, children: labels['portalUnreachable.retry'] }), phone && (_jsx("a", { href: format.telHref(phone), className: link, children: renderLabel(labels['portalUnreachable.call'], { phone }) })), logout] })] }));
19
19
  }
@@ -1,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.
@@ -1,9 +1,9 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { cn } from '../../lib/utils.js';
4
- import { labelParts } from '../internal/label-parts.js';
4
+ import { joinLabelParts, labelParts, renderLabel } from '../internal/label-parts.js';
5
5
  import { bookingButtonClass } from '../internal/ui.js';
6
- 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,29 +29,25 @@ 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. */
38
38
  function whatLine(booking, labels) {
39
- return [booking.serviceName ?? labels['upcoming.serviceFallback'], booking.bookedForName]
40
- .filter(Boolean)
41
- .join(SEPARATOR);
39
+ return joinLabelParts([booking.serviceName ?? labels['upcoming.serviceFallback'], booking.bookedForName], SEPARATOR);
42
40
  }
43
41
  /** When, with whom, and what it costs. */
44
42
  function whenLine(booking, now, labels, format) {
45
- return [
46
- fillLabel(labels['upcoming.when'], {
43
+ return joinLabelParts([
44
+ renderLabel(labels['upcoming.when'], {
47
45
  day: format.clock.dayLabel(booking.startTs, now),
48
46
  time: format.clock.formatTime(booking.startTs),
49
47
  }),
50
48
  booking.resourceName ?? labels['upcoming.anyStylist'],
51
49
  booking.amountOre === null ? null : format.price(booking.amountOre),
52
- ]
53
- .filter(Boolean)
54
- .join(SEPARATOR);
50
+ ], SEPARATOR);
55
51
  }
56
52
  /**
57
53
  * «Upcoming appointments» — one card per appointment still ahead.
@@ -72,7 +68,7 @@ export function UpcomingBookings({ bookings, phone, labels, format, bookingHref,
72
68
  export function DefaultBookingCard({ booking, phone, now, hero, labels, format, classNames, }) {
73
69
  return (_jsxs("li", { className: cn('space-y-2 px-5 py-4', hero
74
70
  ? slotClass(classNames, 'cardHero', 'rounded-2xl bg-primary p-6 text-primary-foreground md:p-8')
75
- : slotClass(classNames, 'card', 'rounded-lg border border-border bg-card')), children: [hero && (_jsxs("p", { className: slotClass(classNames, 'badge', 'inline-flex items-center gap-2 rounded-full bg-primary-foreground/15 px-3 py-1 text-xs font-bold'), children: [_jsx("span", { "aria-hidden": "true", className: "size-2 rounded-full bg-current" }), fillLabel(labels['upcoming.next'], {
71
+ : slotClass(classNames, 'card', 'rounded-lg border border-border bg-card')), children: [hero && (_jsxs("p", { className: slotClass(classNames, 'badge', 'inline-flex items-center gap-2 rounded-full bg-primary-foreground/15 px-3 py-1 text-xs font-bold'), children: [_jsx("span", { "aria-hidden": "true", className: "size-2 rounded-full bg-current" }), renderLabel(labels['upcoming.next'], {
76
72
  when: howFarOff(booking.startTs, now, labels, format),
77
73
  })] })), _jsx("p", { className: cn('font-semibold', hero && 'font-sans text-2xl md:text-3xl'), children: whatLine(booking, labels) }), _jsx("p", { className: cn('tabular-nums', hero && slotClass(classNames, 'heroMuted', 'text-primary-foreground/85')), children: whenLine(booking, now, labels, format) }), booking.managePath ? (_jsx("a", { href: booking.managePath, className: slotClass(classNames, 'manage', 'text-sm font-semibold underline underline-offset-4', hero
78
74
  ? 'inline-block rounded-lg bg-background px-4 py-2 text-foreground no-underline'
@@ -80,7 +76,7 @@ export function DefaultBookingCard({ booking, phone, now, hero, labels, format,
80
76
  ? slotClass(classNames, 'heroMuted', 'text-primary-foreground/85')
81
77
  : 'text-muted-foreground'), children: phone
82
78
  ? labelParts(labels['upcoming.noManageWithPhone'], {
83
- call: (_jsx("a", { href: format.telHref(phone), className: cn('font-semibold underline underline-offset-4', hero ? 'text-primary-foreground' : 'text-primary'), children: fillLabel(labels['upcoming.call'], { phone }) })),
79
+ call: (_jsx("a", { href: format.telHref(phone), className: cn('font-semibold underline underline-offset-4', hero ? 'text-primary-foreground' : 'text-primary'), children: renderLabel(labels['upcoming.call'], { phone }) })),
84
80
  })
85
81
  : // No number on file: the sentence without a link, because a `tel:`
86
82
  // that dials nothing fails in the hand of someone already stuck.
@@ -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.
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useState } from 'react';
4
4
  import { cn } from '../../lib/utils.js';
5
- import { fillLabel } from '../labels.js';
5
+ import { renderLabel } from '../internal/label-parts.js';
6
6
  import { slotClass } from '../slots.js';
7
7
  export const VISIT_HISTORY_LABEL_KEYS = [
8
8
  'history.heading',
@@ -60,7 +60,7 @@ export function VisitHistory({ past, labels, format, years: yearsProp, headingId
60
60
  return (_jsx("button", { type: "button", "aria-pressed": pressed, onClick: () => setSelected(year.year), className: cn(slotClass(classNames, 'yearChip', 'rounded-full border px-4 py-1.5 text-sm font-semibold transition-colors', pressed
61
61
  ? 'border-foreground bg-foreground text-background'
62
62
  : 'border-border hover:bg-muted'), pressed && classNames?.yearChipSelected), children: year.year }, year.year));
63
- }) })), _jsx("ul", { className: slotClass(classNames, 'list', 'divide-y divide-border border-y border-border'), children: shown.visits.map((visit) => (_jsx(Row, { visit: visit, labels: labels, format: format, classNames: classNames }, visit.bookingId))) }), _jsxs("p", { className: slotClass(classNames, 'total', 'flex flex-wrap items-baseline justify-between gap-3 rounded-lg bg-muted px-5 py-4'), children: [_jsx("span", { className: "font-semibold", children: fillLabel(labels[shown.visits.length === 1 ? 'history.count.one' : 'history.count.other'], { count: shown.visits.length, year: shown.year }) }), _jsx("span", { className: "font-sans text-lg font-bold tabular-nums", children: shown.totalOre === null
63
+ }) })), _jsx("ul", { className: slotClass(classNames, 'list', 'divide-y divide-border border-y border-border'), children: shown.visits.map((visit) => (_jsx(Row, { visit: visit, labels: labels, format: format, classNames: classNames }, visit.bookingId))) }), _jsxs("p", { className: slotClass(classNames, 'total', 'flex flex-wrap items-baseline justify-between gap-3 rounded-lg bg-muted px-5 py-4'), children: [_jsx("span", { className: "font-semibold", children: renderLabel(labels[shown.visits.length === 1 ? 'history.count.one' : 'history.count.other'], { count: shown.visits.length, year: shown.year }) }), _jsx("span", { className: "font-sans text-lg font-bold tabular-nums", children: shown.totalOre === null
64
64
  ? labels['history.unknownAmount']
65
65
  : format.price(shown.totalOre) })] })] }))] }));
66
66
  }
@@ -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
  /**
@@ -10,15 +11,16 @@ import type { BookingServiceDto } from './types.js';
10
11
  * already holds a child.
11
12
  */
12
13
  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>;
14
+ export type ServiceScreenLabels = Record<(typeof SERVICE_SCREEN_LABEL_KEYS)[number], BookingLabel>;
14
15
  /**
15
16
  * - `root` — the `<section>`
16
17
  * - `heading` — the `<h2>`
17
18
  * - `pill` / `pillSelected` — a category pill / the marked one
18
19
  * - `groupHeading` — a category's (or a party member's) `<h3>`
19
20
  * - `card` / `cardSelected` — a service card / a pressed one (passed to `ServiceCard`)
21
+ * - `price` — a card's price (passed to `ServiceCard`, and the «same as last time» card's)
20
22
  */
21
- export type ServiceScreenSlot = 'root' | 'heading' | 'pill' | 'pillSelected' | 'groupHeading' | 'card' | 'cardSelected';
23
+ export type ServiceScreenSlot = 'root' | 'heading' | 'pill' | 'pillSelected' | 'groupHeading' | 'card' | 'cardSelected' | 'price';
22
24
  /** One service group, in display order. */
23
25
  export interface ServiceScreenCategory {
24
26
  key: string;
@@ -56,6 +58,8 @@ export interface ServiceCardProps {
56
58
  labels: ServiceScreenLabels;
57
59
  className?: string;
58
60
  selectedClassName?: string;
61
+ /** Merged onto the price (the `price` slot). */
62
+ priceClassName?: string;
59
63
  }
60
64
  export interface ServiceScreenComponents {
61
65
  ServiceCard?: ComponentType<ServiceCardProps>;
@@ -134,4 +138,4 @@ export declare function ServiceScreen(props: ServiceScreenProps): import("react"
134
138
  * online, and a tap target that accepts the tap and does nothing is the dead
135
139
  * end the phone number replaces. The card stays so the catalogue is whole.
136
140
  */
137
- export declare function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, }: ServiceCardProps): import("react").JSX.Element;
141
+ export declare function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, priceClassName, }: ServiceCardProps): import("react").JSX.Element;
@@ -2,8 +2,9 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useState } from 'react';
4
4
  import { cn } from '../lib/utils.js';
5
+ import { renderLabel } from './internal/label-parts.js';
5
6
  import { BookingButton } from './internal/ui.js';
6
- import { fillLabel } from './labels.js';
7
+ import { fillLabel, labelText } from './labels.js';
7
8
  import { slotClass } from './slots.js';
8
9
  /**
9
10
  * The service step — what should be done?
@@ -36,7 +37,7 @@ const asIs = (name) => name;
36
37
  /** The age divider's sentence for `name`. */
37
38
  function ageDividerLabel(labels, name, possessive) {
38
39
  if (!name)
39
- return labels['service.ageDivider.unnamed'];
40
+ return labelText(labels['service.ageDivider.unnamed']);
40
41
  return fillLabel(labels['service.ageDivider.named'], {
41
42
  name,
42
43
  nameGenitive: possessive(name),
@@ -57,6 +58,7 @@ function cardProps(shared) {
57
58
  labels: shared.labels,
58
59
  className: shared.classNames?.card,
59
60
  selectedClassName: shared.classNames?.cardSelected,
61
+ priceClassName: shared.classNames?.price,
60
62
  };
61
63
  }
62
64
  export function ServiceScreen(props) {
@@ -187,7 +189,7 @@ function PartyServiceScreen({ services, categories, categoryOf, childCategory, s
187
189
  */
188
190
  function NothingInThisParty({ person, labels, onRemove, }) {
189
191
  const values = { label: person.label };
190
- return (_jsxs("div", { className: "space-y-2 rounded-lg border border-dashed border-border px-5 py-4 text-sm", children: [_jsx("p", { children: fillLabel(labels[person.adult ? 'service.party.adultAlone' : 'service.party.nothingFits'], values) }), onRemove && (_jsx("button", { type: "button", onClick: onRemove, className: "font-semibold text-primary underline underline-offset-4", children: fillLabel(labels['service.party.remove'], values) }))] }));
192
+ return (_jsxs("div", { className: "space-y-2 rounded-lg border border-dashed border-border px-5 py-4 text-sm", children: [_jsx("p", { children: renderLabel(labels[person.adult ? 'service.party.adultAlone' : 'service.party.nothingFits'], values) }), onRemove && (_jsx("button", { type: "button", onClick: onRemove, className: "font-semibold text-primary underline underline-offset-4", children: renderLabel(labels['service.party.remove'], values) }))] }));
191
193
  }
192
194
  /**
193
195
  * One person with a «same as last time»: the suggestion as the answer, and the
@@ -206,7 +208,7 @@ function SameAsLast({ suggestion, selected, onPick, shared, }) {
206
208
  const { labels, format, classNames } = shared;
207
209
  return (_jsxs("div", { className: "space-y-1", children: [_jsxs("button", { type: "button", "aria-pressed": selected, onClick: onPick, className: cn('flex min-h-14 w-full items-baseline justify-between gap-4 rounded-lg border px-5 py-4 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', selected
208
210
  ? 'border-primary bg-primary/5 ring-2 ring-primary'
209
- : 'border-primary/60 bg-card hover:border-primary', classNames?.card, selected && classNames?.cardSelected), children: [_jsxs("span", { children: [_jsx("span", { className: "block text-xs font-bold tracking-wide text-primary uppercase", children: labels['service.sameAsLast'] }), _jsx("span", { className: "font-medium", children: service.name })] }), _jsxs("span", { className: "flex items-baseline gap-4 text-right", children: [_jsx("span", { className: "text-sm text-muted-foreground tabular-nums", children: fillLabel(labels['service.duration'], { minutes: service.durationMinutes }) }), _jsx("span", { className: "whitespace-nowrap font-semibold text-foreground tabular-nums", children: format.price(service.priceOre) })] })] }), note && _jsx("p", { className: "text-sm text-muted-foreground", children: note })] }));
211
+ : 'border-primary/60 bg-card hover:border-primary', classNames?.card, selected && classNames?.cardSelected), children: [_jsxs("span", { children: [_jsx("span", { className: "block text-xs font-bold tracking-wide text-primary uppercase", children: labels['service.sameAsLast'] }), _jsx("span", { className: "font-medium", children: service.name })] }), _jsxs("span", { className: "flex items-baseline gap-4 text-right", children: [_jsx("span", { className: "text-sm text-muted-foreground tabular-nums", children: renderLabel(labels['service.duration'], { minutes: service.durationMinutes }) }), _jsx("span", { className: slotClass(classNames, 'price', 'whitespace-nowrap font-semibold text-foreground tabular-nums'), children: format.price(service.priceOre) })] })] }), note && _jsx("p", { className: "text-sm text-muted-foreground", children: note })] }));
210
212
  }
211
213
  /**
212
214
  * The default service card.
@@ -218,9 +220,9 @@ function SameAsLast({ suggestion, selected, onPick, shared, }) {
218
220
  * online, and a tap target that accepts the tap and does nothing is the dead
219
221
  * end the phone number replaces. The card stays so the catalogue is whole.
220
222
  */
221
- export function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, }) {
222
- const duration = fillLabel(labels['service.duration'], { minutes: service.durationMinutes });
223
- const price = (_jsx("span", { className: "whitespace-nowrap font-semibold text-foreground tabular-nums", children: format.price(service.priceOre) }));
223
+ export function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, priceClassName, }) {
224
+ const duration = renderLabel(labels['service.duration'], { minutes: service.durationMinutes });
225
+ const price = (_jsx("span", { className: cn('whitespace-nowrap font-semibold text-foreground tabular-nums', priceClassName), children: format.price(service.priceOre) }));
224
226
  if (kind === 'phone') {
225
227
  return (_jsxs("div", { className: cn('flex min-h-14 w-full items-baseline justify-between gap-4 rounded-lg border border-dashed border-border px-5 py-4', className), children: [_jsx("span", { className: "font-medium", children: service.name }), _jsxs("span", { className: "flex items-baseline gap-4 text-right", children: [_jsx("span", { className: "text-sm text-muted-foreground tabular-nums", children: duration }), _jsxs("span", { className: "text-sm", children: [labels['service.phoneOnly'], phone ? (_jsxs(_Fragment, { children: [' – ', _jsx("a", { href: format.telHref(phone), "aria-label": fillLabel(labels['service.phoneOnlyLinkLabel'], { phone }), className: "font-semibold text-primary underline underline-offset-4", children: phone })] })) : null] })] })] }));
226
228
  }
@@ -1,5 +1,6 @@
1
1
  import { type ComponentType, type Ref } 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 { BookingResourceDto, PartyMode } from './types.js';
5
6
  /**
@@ -20,15 +21,16 @@ import type { BookingResourceDto, PartyMode } from './types.js';
20
21
  * available» included. Arrow keys move and choose, Tab enters and leaves once.
21
22
  */
22
23
  export declare const STYLIST_SCREEN_LABEL_KEYS: readonly ["stylist.heading", "stylist.firstAvailable", "stylist.firstAvailableSubtitle", "stylist.firstAvailableBadge", "stylist.pendingName", "stylist.nextAvailable", "stylist.loading", "stylist.party.legend", "stylist.party.sequential", "stylist.party.sequentialMinutes", "stylist.party.parallel.two", "stylist.party.parallel.three", "stylist.party.parallel.other", "stylist.party.parallelMinutes", "stylist.party.parallelNote.two", "stylist.party.parallelNote.three", "stylist.party.parallelNote.other"];
23
- export type StylistScreenLabels = Record<(typeof STYLIST_SCREEN_LABEL_KEYS)[number], string>;
24
+ export type StylistScreenLabels = Record<(typeof STYLIST_SCREEN_LABEL_KEYS)[number], BookingLabel>;
24
25
  /**
25
26
  * - `root` — the `<section>`
26
27
  * - `heading` — the `<h2>`
27
28
  * - `group` — the radiogroup (the sideways row / the column)
28
29
  * - `card` / `cardSelected` — a stylist option / the checked one (passed to `StylistCard`)
29
30
  * - `modeCard` / `modeCardSelected` — a family's mode card / the pressed one
31
+ * - `nextAvailable` — a stylist option's next-opening line (passed to `StylistCard`)
30
32
  */
31
- export type StylistScreenSlot = 'root' | 'heading' | 'group' | 'card' | 'cardSelected' | 'modeCard' | 'modeCardSelected';
33
+ export type StylistScreenSlot = 'root' | 'heading' | 'group' | 'card' | 'cardSelected' | 'modeCard' | 'modeCardSelected' | 'nextAvailable';
32
34
  /** One option of the radiogroup as the card draws it. */
33
35
  export interface StylistOption {
34
36
  /** `null` is «first available». */
@@ -55,6 +57,8 @@ export interface StylistCardProps {
55
57
  labels: StylistScreenLabels;
56
58
  className?: string;
57
59
  selectedClassName?: string;
60
+ /** Merged onto the next-opening line (the `nextAvailable` slot). */
61
+ nextAvailableClassName?: string;
58
62
  }
59
63
  export interface StylistScreenComponents {
60
64
  StylistCard?: ComponentType<StylistCardProps>;
@@ -102,6 +106,14 @@ export interface StylistScreenProps {
102
106
  mode: PartyMode;
103
107
  /** How many people, for the option labels. */
104
108
  size: number;
109
+ /**
110
+ * The size as the copy spells it (e.g. «two»), filled into `{sizeWord}`
111
+ * in `stylist.party.parallel.*` and `stylist.party.parallelNote.*`.
112
+ * Default: the number. Lets a pack keep the word as its own text piece
113
+ * (`'We find {sizeWord} stylists …'`) rather than writing it into the
114
+ * sentence.
115
+ */
116
+ sizeWord?: string;
105
117
  /** How long the visit is in each mode. */
106
118
  minutes: {
107
119
  sequential: number;
@@ -114,4 +126,4 @@ export interface StylistScreenProps {
114
126
  }
115
127
  export declare function StylistScreen({ labels, format, now, serviceIds, resources, selectedResourceId, nextAvailableTs, nextAvailableLoading, loading, skeletonCount, pendingName, notice, onPick, party, classNames, components, }: StylistScreenProps): import("react").JSX.Element;
116
128
  /** The default stylist option: avatar (photo or badge), name(s), bio, next opening. */
117
- export declare function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, labels, className, selectedClassName, }: StylistCardProps): import("react").JSX.Element;
129
+ export declare function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, labels, className, selectedClassName, nextAvailableClassName, }: StylistCardProps): import("react").JSX.Element;