@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
@@ -0,0 +1,83 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Check } from 'lucide-react';
4
+ import { cn } from '../../lib/utils.js';
5
+ import { LiveStatus } from '../live-status.js';
6
+ import { slotClass } from '../slots.js';
7
+ import { renderLabel } from './label-parts.js';
8
+ import { BookingButton } from './ui.js';
9
+ /**
10
+ * The service step's multi-select parts (`ServiceScreenProps.selection`):
11
+ * the per-person tabs, the sticky total bar and the checkbox card. Internal —
12
+ * `ServiceScreen` composes them; the package does not export them.
13
+ */
14
+ export const serviceTabId = (index) => `booking-service-tab-${index}`;
15
+ export const serviceTabPanelId = (index) => `booking-service-panel-${index}`;
16
+ /**
17
+ * One tab per person on the multi-select step, a tick on everyone who has
18
+ * something. The WAI-ARIA tabs pattern: one tab stop, arrows move between
19
+ * people, Home / End jump to the ends; moving selects (one short panel, no
20
+ * cost to showing it).
21
+ */
22
+ export function PersonTabs({ people, active, done, onSelect, labels, labelledBy, describedBy, classNames, }) {
23
+ function onKeyDown(event) {
24
+ const last = people.length - 1;
25
+ const moves = {
26
+ ArrowRight: active === last ? 0 : active + 1,
27
+ ArrowLeft: active === 0 ? last : active - 1,
28
+ Home: 0,
29
+ End: last,
30
+ };
31
+ const next = Object.hasOwn(moves, event.key) ? moves[event.key] : null;
32
+ if (next === null)
33
+ return;
34
+ event.preventDefault();
35
+ onSelect(next);
36
+ document.getElementById(serviceTabId(next))?.focus();
37
+ }
38
+ return (_jsx("div", { role: "tablist", "aria-labelledby": labelledBy, "aria-describedby": describedBy, onKeyDown: onKeyDown, className: "-mx-1 flex gap-2 overflow-x-auto px-1 py-1", children: people.map((person, index) => {
39
+ const selected = index === active;
40
+ const finished = done(index);
41
+ return (_jsxs("button", { type: "button", role: "tab", id: serviceTabId(index), "aria-selected": selected, "aria-controls": selected ? serviceTabPanelId(index) : undefined, tabIndex: selected ? 0 : -1, onClick: () => onSelect(index),
42
+ // Weight and ring carry the open tab alongside the colour, as the
43
+ // category pills do. Focus is an outline, so it stacks on the
44
+ // selection ring instead of replacing it with a fainter one.
45
+ className: cn('inline-flex h-11 shrink-0 items-center gap-1.5 rounded-full border-[1.5px] px-4 text-sm whitespace-nowrap transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring', selected
46
+ ? 'border-primary bg-primary/10 font-bold text-foreground ring-2 ring-primary'
47
+ : 'border-primary/35 font-normal text-primary hover:border-primary hover:bg-primary/5', classNames?.tab, selected && classNames?.tabSelected), children: [finished && _jsx(Check, { "aria-hidden": "true", className: "size-4 shrink-0" }), person.label, finished && (_jsxs("span", { className: "sr-only", children: [', ', renderLabel(labels['service.tabDone'])] }))] }, person.key));
48
+ }) }));
49
+ }
50
+ /**
51
+ * The multi-select step's sticky bar: the running total, what is still
52
+ * missing, and «Next». The same bar as `SummaryBar`, so the step reads as one
53
+ * flow; a caller that also renders `SummaryBar` hides it on this step.
54
+ *
55
+ * «Next» is never disabled. A disabled button explains nothing and is skipped
56
+ * by a screen reader's tab order; this one stays, the line says what is
57
+ * missing, and the caller decides what pressing it does.
58
+ */
59
+ export function TotalBar({ selection, format, classNames, }) {
60
+ const { total, canContinue, labels } = selection;
61
+ return (_jsxs("div", { className: slotClass(classNames, 'bar', 'sticky bottom-0 z-40 border-t border-border bg-background/95 backdrop-blur-md'), children: [_jsx(LiveStatus, { text: selection.notice ?? null, className: "px-5 text-sm font-medium text-foreground [&:not(:empty)]:pt-3" }), _jsxs("div", { className: "flex h-16 items-center justify-between gap-4 px-5", children: [_jsxs("div", { "aria-live": "polite", "aria-atomic": "true", className: "min-w-0 text-sm tabular-nums", children: [total !== null && (_jsx("p", { className: "truncate font-semibold", children: renderLabel(labels['service.total'], {
62
+ minutes: total.minutes,
63
+ price: format.price(total.priceOre),
64
+ }) })), !canContinue && (_jsx("p", { className: "truncate text-muted-foreground", children: renderLabel(labels['service.chooseFirst']) }))] }), _jsx(BookingButton, { size: "lg", onClick: selection.onContinue, children: renderLabel(labels['service.continue']) })] })] }));
65
+ }
66
+ /**
67
+ * A service card on the multi-select step: a real checkbox inside its label.
68
+ * Space (and Enter) toggles it, the label's whole box is the target, and a
69
+ * screen reader hears «checkbox, checked».
70
+ */
71
+ export function MultiServiceCard({ name, selected, onPick, duration, price, className, selectedClassName, }) {
72
+ return (_jsxs("label", { className: cn('flex min-h-14 w-full cursor-pointer items-center gap-4 rounded-lg border px-5 py-4 text-left transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary', selected ? 'border-primary bg-primary/5' : 'border-border bg-card hover:border-primary', className, selected && selectedClassName), children: [_jsx("input", { type: "checkbox", className: "sr-only", checked: selected, onChange: onPick,
73
+ // Enter as well as Space: on a phone-sized list the card reads as a
74
+ // button, and a keyboard visitor presses it like one.
75
+ onKeyDown: (event) => {
76
+ if (event.key !== 'Enter')
77
+ return;
78
+ event.preventDefault();
79
+ onPick();
80
+ } }), _jsx("span", { "aria-hidden": "true", className: cn('flex size-6 shrink-0 items-center justify-center rounded-[4px] border-[1.5px] transition-colors',
81
+ // The empty box needs 3:1 against the card to be seen at all.
82
+ selected ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground'), children: selected && _jsx(Check, { className: "size-4" }) }), _jsxs("span", { className: "flex min-w-0 flex-1 items-baseline justify-between gap-4", children: [_jsx("span", { className: "font-medium", children: name }), _jsxs("span", { className: "flex items-baseline gap-4 text-right", children: [_jsx("span", { className: "text-sm text-muted-foreground tabular-nums", children: duration }), price] })] })] }));
83
+ }
@@ -0,0 +1,13 @@
1
+ import type { WizardService } from '../types.js';
2
+ /**
3
+ * One person's visit: their first service and, when they booked more, the
4
+ * ones after it — done back to back, with one stylist.
5
+ */
6
+ export interface VisitLike {
7
+ service: WizardService;
8
+ extraServices?: ReadonlyArray<WizardService>;
9
+ }
10
+ /** Every service in the visit, in the order they are done. */
11
+ export declare function visitServices(item: VisitLike): WizardService[];
12
+ /** The visit's services by name: «Klipp + Vask». */
13
+ export declare function visitName(item: VisitLike): string;
@@ -0,0 +1,10 @@
1
+ /** Every service in the visit, in the order they are done. */
2
+ export function visitServices(item) {
3
+ return [item.service, ...(item.extraServices ?? [])];
4
+ }
5
+ /** The visit's services by name: «Klipp + Vask». */
6
+ export function visitName(item) {
7
+ return visitServices(item)
8
+ .map((service) => service.name)
9
+ .join(' + ');
10
+ }
@@ -2,6 +2,7 @@ import { ADD_CHILD_SHEET_LABEL_KEYS } from './add-child-sheet.js';
2
2
  import { BOOKING_SKELETON_LABEL_KEYS } from './booking-skeleton.js';
3
3
  import { CONFIRMATION_LABEL_KEYS } from './confirmation.js';
4
4
  import { DETAILS_SCREEN_LABEL_KEYS } from './details-screen.js';
5
+ import type { BookingLabel } from './labels.js';
5
6
  import { LOGIN_SHEET_LABEL_KEYS } from './login-sheet.js';
6
7
  import { MANAGE_SCREEN_LABEL_KEYS } from './manage-screen.js';
7
8
  import { CHILD_CARDS_LABEL_KEYS } from './portal/child-cards.js';
@@ -15,7 +16,7 @@ import { REBOOK_CARDS_LABEL_KEYS } from './portal/rebook-cards.js';
15
16
  import { UPCOMING_BOOKINGS_LABEL_KEYS } from './portal/upcoming-bookings.js';
16
17
  import { VIPPS_LINK_ROW_LABEL_KEYS } from './portal/vipps-link-row.js';
17
18
  import { VISIT_HISTORY_LABEL_KEYS } from './portal/visit-history.js';
18
- import { SERVICE_SCREEN_LABEL_KEYS } from './service-screen.js';
19
+ import { SERVICE_SCREEN_LABEL_KEYS, SERVICE_SCREEN_MULTI_LABEL_KEYS } from './service-screen.js';
19
20
  import { STYLIST_SCREEN_LABEL_KEYS } from './stylist-screen.js';
20
21
  import { SUMMARY_BAR_LABEL_KEYS } from './summary-bar.js';
21
22
  import { TIME_SCREEN_LABEL_KEYS } from './time-screen.js';
@@ -28,7 +29,14 @@ import { WHO_SCREEN_LABEL_KEYS } from './who-screen.js';
28
29
  * (Screens that embed another screen already include its keys, e.g. the
29
30
  * login sheet carries the login panel's, OTP slots' and Vipps button's.)
30
31
  */
31
- export declare const BOOKING_LABEL_KEYS: readonly ("addChild.trigger" | "addChild.title" | "addChild.description.saved" | "addChild.description.local" | "addChild.name" | "addChild.birthYear" | "addChild.birthYearPlaceholder" | "addChild.birthMonth" | "addChild.birthMonthUnknown" | "addChild.notes" | "addChild.missing" | "addChild.submit" | "addChild.close" | "bookingSkeleton.opening" | "confirmation.heading" | "confirmation.serviceFor" | "confirmation.when" | "confirmation.party.heading" | "confirmation.party.line" | "confirmation.party.stylist" | "confirmation.party.total" | "confirmation.calendar" | "confirmation.manage" | "confirmation.manageFor" | "confirmation.portal" | "confirmation.startOver" | "details.heading" | "details.phone.label" | "details.phone.prefix" | "details.phone.error" | "details.name.label" | "details.known.label" | "details.known.for" | "details.known.self" | "details.known.adult" | "details.known.child" | "details.child.name" | "details.child.year" | "details.child.yearPlaceholder" | "details.email.label" | "details.email.help" | "details.email.error" | "details.notes.label" | "details.notes.placeholder" | "details.terms.text" | "details.terms.link" | "details.marketing.text" | "details.error.maxParty" | "details.error.slotTaken" | "details.error.conflict" | "details.error.invalidInput" | "details.error.unconfigured" | "details.error.upstreamError" | "details.error.inProgress" | "details.call.link" | "details.call.none" | "details.submit" | "otp.label" | "vipps.button" | "vipps.unavailable" | "vipps.throttled" | "login.heading" | "login.codeHeading" | "login.or" | "login.emailIntro" | "login.emailLabel" | "login.sendCode" | "login.codeLabel" | "login.submitCode" | "login.codeHelp" | "login.codeHelpVipps" | "login.vippsSentTo" | "login.vippsSentToUnknown" | "login.cooldownWait" | "login.cooldownReady" | "login.resendIn" | "login.resend" | "login.switchEmail" | "login.useEmailInstead" | "login.notice.sent" | "login.notice.resent" | "login.notice.unreachable" | "login.notice.badEmail" | "login.notice.invalid" | "login.notice.throttled" | "login.notice.vippsInvalid" | "login.notice.vippsConflict" | "loginSheet.prompt" | "loginSheet.trigger" | "loginSheet.intro" | "loginSheet.vipps" | "loginSheet.close" | "time.heading" | "time.dayStrip.legend" | "time.nearest" | "time.current" | "time.nextFree" | "time.shortNotice" | "time.weekend.pct" | "time.weekend.mixed" | "time.month.show" | "time.month.hide" | "time.month.previous" | "time.month.next" | "time.month.heading" | "time.month.note" | "time.party.sequential" | "time.party.parallel" | "time.party.seat" | "time.party.seatSeparator" | "time.parallel.offer.two" | "time.parallel.offer.other" | "time.parallel.accept" | "time.empty.closed" | "time.empty.over" | "time.empty.full" | "time.empty.stylist" | "time.empty.unknown" | "time.empty.callLink" | "time.empty.callLinkAria" | "time.empty.callSuffix" | "time.empty.callPlain" | "time.loading" | "time.skeleton.dayPlaceholder" | "time.taken.message" | "time.taken.close" | "manage.heading" | "manage.serviceFallback" | "manage.card.named" | "manage.card.when" | "manage.card.whenWithStylist" | "manage.appointment.named" | "manage.appointment.namedSibilant" | "manage.appointment.unnamed" | "manage.ringUs" | "manage.ringUsOn" | "manage.callUs" | "manage.callUsOn" | "manage.error.windowPassed" | "manage.error.slotTaken" | "manage.error.notFound" | "manage.error.conflict" | "manage.error.invalidInput" | "manage.error.unavailable" | "manage.freeUntil" | "manage.remaining.in" | "manage.remaining.daysAndHours" | "manage.remaining.underMinute" | "manage.remaining.day.one" | "manage.remaining.day.other" | "manage.remaining.hour.one" | "manage.remaining.hour.other" | "manage.remaining.minute.one" | "manage.remaining.minute.other" | "manage.change" | "manage.cancel" | "manage.callToChange" | "manage.callToCancel" | "manage.blocked" | "manage.blocked.suffix" | "manage.blocked.plain" | "manage.calendar" | "manage.closed.cancelled" | "manage.closed.completed" | "manage.closed.noShow" | "manage.closed.pending" | "manage.closed.pendingCall" | "manage.closed.pendingCallPlain" | "manage.bookNew" | "manage.party.heading" | "manage.party.justThisOne" | "manage.party.unnamed" | "manage.party.note" | "manage.party.noteSuffix" | "manage.back" | "manage.reschedule.lead" | "manage.reschedule.leadWithStylist" | "manage.reschedule.failed" | "manage.reschedule.failedSuffix" | "manage.reschedule.loading" | "manage.move.question" | "manage.move.questionWithStylist" | "manage.move.surcharge" | "manage.move.confirm" | "manage.move.pickAnother" | "manage.cancel.question" | "manage.cancel.reasonLegend" | "manage.cancel.reason.illness" | "manage.cancel.reason.noLongerSuits" | "manage.cancel.reason.other" | "manage.cancel.confirm" | "manage.cancel.keep" | "manage.cancelled" | "manage.cancelled.findNew" | "manage.moved" | "manage.movedTo" | "manage.moved.view" | "manage.moved.noLink" | "manage.moved.noLinkSuffix" | "manage.portal" | "childCards.empty" | "childCards.neverVisited" | "childCards.lastVisit" | "childCards.lastVisitNoService" | "childCards.book" | "childCards.stylist" | "childCards.next" | "childCards.none" | "dataControls.heading" | "dataControls.export" | "dataControls.delete" | "dataControls.confirmHeading" | "dataControls.consequences" | "dataControls.confirmLabel" | "dataControls.confirmHelp" | "dataControls.wrongWord" | "dataControls.deleteForever" | "dataControls.cancel" | "dataControls.unreachable" | "dataControls.confirmWord" | "ageConfirm.heading" | "ageConfirm.lead" | "ageConfirm.legend" | "ageConfirm.birthYear" | "ageConfirm.birthMonth" | "ageConfirm.birthMonthUnknown" | "ageConfirm.confirm" | "ageConfirm.dismiss" | "familyEditor.heading" | "familyEditor.lead" | "familyEditor.empty" | "familyEditor.legend" | "familyEditor.add" | "familyEditor.name" | "familyEditor.birthYear" | "familyEditor.birthYearPlaceholder" | "familyEditor.birthMonth" | "familyEditor.birthMonthUnknown" | "familyEditor.stylist" | "familyEditor.stylistNone" | "familyEditor.stylistFormer" | "familyEditor.notes" | "familyEditor.notesPlaceholder" | "familyEditor.newRow" | "familyEditor.save" | "familyEditor.remove" | "familyEditor.removeNamed" | "familyEditor.removeRow" | "familyEditor.saved" | "familyEditor.savedNoDetails" | "familyEditor.partial" | "familyEditor.removed" | "familyEditor.unreachable" | "familyEditor.ageConfirmed" | "familyEditor.ageSaved" | "logout.button" | "logout.unreachable" | "portalShell.nav" | "portalShell.navBar" | "portalUnreachable.heading" | "portalUnreachable.body" | "portalUnreachable.retry" | "portalUnreachable.call" | "profileForm.heading" | "profileForm.firstName" | "profileForm.lastName" | "profileForm.phone" | "profileForm.email" | "profileForm.emailHelp" | "profileForm.save" | "profileForm.saved" | "profileForm.restored" | "profileForm.marketing" | "profileForm.unreachable" | "rebook.heading" | "rebook.empty" | "rebook.serviceFallback" | "rebook.anyStylist" | "rebook.cta" | "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" | "vippsLink.heading" | "vippsLink.linked" | "vippsLink.pitch" | "vippsLink.button" | "vippsLink.success" | "vippsLink.conflict" | "vippsLink.failed" | "vippsLink.unavailable" | "vippsLink.throttled" | "history.heading" | "history.empty" | "history.serviceFallback" | "history.count.one" | "history.count.other" | "history.unknownAmount" | "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" | "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" | "summary.placeholder.who" | "summary.placeholder.service" | "summary.next" | "who.heading" | "who.guest.groupLabel" | "who.guest.children.one" | "who.guest.children.other" | "who.guest.adult" | "who.guest.addedList" | "who.family.legend" | "who.family.self" | "who.family.limit")[];
32
- export type BookingLabelKey = (typeof WHO_SCREEN_LABEL_KEYS)[number] | (typeof ADD_CHILD_SHEET_LABEL_KEYS)[number] | (typeof SERVICE_SCREEN_LABEL_KEYS)[number] | (typeof STYLIST_SCREEN_LABEL_KEYS)[number] | (typeof TIME_SCREEN_LABEL_KEYS)[number] | (typeof MANAGE_SCREEN_LABEL_KEYS)[number] | (typeof DETAILS_SCREEN_LABEL_KEYS)[number] | (typeof SUMMARY_BAR_LABEL_KEYS)[number] | (typeof CONFIRMATION_LABEL_KEYS)[number] | (typeof BOOKING_SKELETON_LABEL_KEYS)[number] | (typeof LOGIN_SHEET_LABEL_KEYS)[number] | (typeof PORTAL_SHELL_LABEL_KEYS)[number] | (typeof PORTAL_UNREACHABLE_LABEL_KEYS)[number] | (typeof UPCOMING_BOOKINGS_LABEL_KEYS)[number] | (typeof VISIT_HISTORY_LABEL_KEYS)[number] | (typeof REBOOK_CARDS_LABEL_KEYS)[number] | (typeof CHILD_CARDS_LABEL_KEYS)[number] | (typeof FAMILY_EDITOR_LABEL_KEYS)[number] | (typeof PROFILE_FORM_LABEL_KEYS)[number] | (typeof DATA_CONTROLS_LABEL_KEYS)[number] | (typeof LOGOUT_BUTTON_LABEL_KEYS)[number] | (typeof VIPPS_LINK_ROW_LABEL_KEYS)[number];
33
- /** A complete label pack for one locale. */
34
- export type BookingLabels = Record<BookingLabelKey, string>;
32
+ export declare const BOOKING_LABEL_KEYS: readonly ("addChild.trigger" | "addChild.title" | "addChild.description.saved" | "addChild.description.local" | "addChild.name" | "addChild.birthYear" | "addChild.birthYearPlaceholder" | "addChild.birthMonth" | "addChild.birthMonthUnknown" | "addChild.notes" | "addChild.missing" | "addChild.submit" | "addChild.close" | "bookingSkeleton.opening" | "confirmation.heading" | "confirmation.serviceFor" | "confirmation.when" | "confirmation.party.heading" | "confirmation.party.line" | "confirmation.party.stylist" | "confirmation.party.total" | "confirmation.calendar" | "confirmation.manage" | "confirmation.manageFor" | "confirmation.portal" | "confirmation.startOver" | "details.heading" | "details.phone.label" | "details.phone.prefix" | "details.phone.error" | "details.name.label" | "details.known.label" | "details.known.for" | "details.known.self" | "details.known.adult" | "details.known.child" | "details.child.name" | "details.child.year" | "details.child.yearPlaceholder" | "details.email.label" | "details.email.help" | "details.email.error" | "details.notes.label" | "details.notes.placeholder" | "details.terms.text" | "details.terms.link" | "details.marketing.text" | "details.error.maxParty" | "details.error.slotTaken" | "details.error.conflict" | "details.error.invalidInput" | "details.error.unconfigured" | "details.error.upstreamError" | "details.error.inProgress" | "details.call.link" | "details.call.none" | "details.submit" | "otp.label" | "vipps.button" | "vipps.unavailable" | "vipps.throttled" | "login.heading" | "login.codeHeading" | "login.or" | "login.emailIntro" | "login.emailLabel" | "login.sendCode" | "login.codeLabel" | "login.submitCode" | "login.codeHelp" | "login.codeHelpVipps" | "login.vippsSentTo" | "login.vippsSentToUnknown" | "login.cooldownWait" | "login.cooldownReady" | "login.resendIn" | "login.resend" | "login.switchEmail" | "login.useEmailInstead" | "login.notice.sent" | "login.notice.resent" | "login.notice.unreachable" | "login.notice.badEmail" | "login.notice.invalid" | "login.notice.throttled" | "login.notice.vippsInvalid" | "login.notice.vippsConflict" | "loginSheet.prompt" | "loginSheet.trigger" | "loginSheet.intro" | "loginSheet.vipps" | "loginSheet.close" | "time.heading" | "time.dayStrip.legend" | "time.nearest" | "time.current" | "time.nextFree" | "time.shortNotice" | "time.weekend.pct" | "time.weekend.mixed" | "time.month.show" | "time.month.hide" | "time.month.previous" | "time.month.next" | "time.month.heading" | "time.month.note" | "time.party.sequential" | "time.party.parallel" | "time.party.seat" | "time.party.seatSeparator" | "time.parallel.offer.two" | "time.parallel.offer.other" | "time.parallel.accept" | "time.empty.closed" | "time.empty.over" | "time.empty.full" | "time.empty.stylist" | "time.empty.unknown" | "time.empty.callLink" | "time.empty.callLinkAria" | "time.empty.callSuffix" | "time.empty.callPlain" | "time.loading" | "time.skeleton.dayPlaceholder" | "time.taken.message" | "time.taken.close" | "manage.heading" | "manage.serviceFallback" | "manage.card.named" | "manage.card.when" | "manage.card.whenWithStylist" | "manage.appointment.named" | "manage.appointment.namedSibilant" | "manage.appointment.unnamed" | "manage.ringUs" | "manage.ringUsOn" | "manage.callUs" | "manage.callUsOn" | "manage.error.windowPassed" | "manage.error.slotTaken" | "manage.error.notFound" | "manage.error.conflict" | "manage.error.invalidInput" | "manage.error.unavailable" | "manage.freeUntil" | "manage.remaining.in" | "manage.remaining.daysAndHours" | "manage.remaining.underMinute" | "manage.remaining.day.one" | "manage.remaining.day.other" | "manage.remaining.hour.one" | "manage.remaining.hour.other" | "manage.remaining.minute.one" | "manage.remaining.minute.other" | "manage.change" | "manage.cancel" | "manage.callToChange" | "manage.callToCancel" | "manage.blocked" | "manage.blocked.suffix" | "manage.blocked.plain" | "manage.calendar" | "manage.closed.cancelled" | "manage.closed.completed" | "manage.closed.noShow" | "manage.closed.pending" | "manage.closed.pendingCall" | "manage.closed.pendingCallPlain" | "manage.bookNew" | "manage.party.heading" | "manage.party.justThisOne" | "manage.party.unnamed" | "manage.party.note" | "manage.party.noteSuffix" | "manage.back" | "manage.reschedule.lead" | "manage.reschedule.leadWithStylist" | "manage.reschedule.failed" | "manage.reschedule.failedSuffix" | "manage.reschedule.loading" | "manage.move.question" | "manage.move.questionWithStylist" | "manage.move.surcharge" | "manage.move.confirm" | "manage.move.pickAnother" | "manage.cancel.question" | "manage.cancel.reasonLegend" | "manage.cancel.reason.illness" | "manage.cancel.reason.noLongerSuits" | "manage.cancel.reason.other" | "manage.cancel.confirm" | "manage.cancel.keep" | "manage.cancelled" | "manage.cancelled.findNew" | "manage.moved" | "manage.movedTo" | "manage.moved.view" | "manage.moved.noLink" | "manage.moved.noLinkSuffix" | "manage.portal" | "childCards.empty" | "childCards.neverVisited" | "childCards.lastVisit" | "childCards.lastVisitNoService" | "childCards.book" | "childCards.stylist" | "childCards.next" | "childCards.none" | "dataControls.heading" | "dataControls.export" | "dataControls.delete" | "dataControls.confirmHeading" | "dataControls.consequences" | "dataControls.confirmLabel" | "dataControls.confirmHelp" | "dataControls.wrongWord" | "dataControls.deleteForever" | "dataControls.cancel" | "dataControls.unreachable" | "dataControls.confirmWord" | "ageConfirm.heading" | "ageConfirm.lead" | "ageConfirm.legend" | "ageConfirm.birthYear" | "ageConfirm.birthMonth" | "ageConfirm.birthMonthUnknown" | "ageConfirm.confirm" | "ageConfirm.dismiss" | "familyEditor.heading" | "familyEditor.lead" | "familyEditor.empty" | "familyEditor.legend" | "familyEditor.add" | "familyEditor.name" | "familyEditor.birthYear" | "familyEditor.birthYearPlaceholder" | "familyEditor.birthMonth" | "familyEditor.birthMonthUnknown" | "familyEditor.stylist" | "familyEditor.stylistNone" | "familyEditor.stylistFormer" | "familyEditor.notes" | "familyEditor.notesPlaceholder" | "familyEditor.newRow" | "familyEditor.save" | "familyEditor.remove" | "familyEditor.removeNamed" | "familyEditor.removeRow" | "familyEditor.saved" | "familyEditor.savedNoDetails" | "familyEditor.partial" | "familyEditor.removed" | "familyEditor.unreachable" | "familyEditor.ageConfirmed" | "familyEditor.ageSaved" | "logout.button" | "logout.unreachable" | "portalShell.nav" | "portalShell.navBar" | "portalUnreachable.heading" | "portalUnreachable.body" | "portalUnreachable.retry" | "portalUnreachable.call" | "profileForm.heading" | "profileForm.firstName" | "profileForm.lastName" | "profileForm.phone" | "profileForm.email" | "profileForm.emailHelp" | "profileForm.save" | "profileForm.saved" | "profileForm.restored" | "profileForm.marketing" | "profileForm.unreachable" | "rebook.heading" | "rebook.empty" | "rebook.serviceFallback" | "rebook.anyStylist" | "rebook.cta" | "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" | "vippsLink.heading" | "vippsLink.linked" | "vippsLink.pitch" | "vippsLink.button" | "vippsLink.success" | "vippsLink.conflict" | "vippsLink.failed" | "vippsLink.unavailable" | "vippsLink.throttled" | "history.heading" | "history.empty" | "history.serviceFallback" | "history.count.one" | "history.count.other" | "history.unknownAmount" | "service.multiHint" | "service.total" | "service.chooseFirst" | "service.continue" | "service.party.nothingBookable" | "service.tabDone" | "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" | "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" | "summary.placeholder.who" | "summary.placeholder.service" | "summary.next" | "who.heading" | "who.guest.groupLabel" | "who.guest.children.one" | "who.guest.children.other" | "who.guest.adult" | "who.guest.addedList" | "who.family.legend" | "who.family.self" | "who.family.limit")[];
33
+ export type BookingLabelKey = BookingRequiredLabelKey | BookingOptionalLabelKey;
34
+ /**
35
+ * Keys added after a screen first shipped, for a feature a caller opts into.
36
+ * A pack may leave them out; a screen using the feature needs them.
37
+ */
38
+ export type BookingOptionalLabelKey = (typeof SERVICE_SCREEN_MULTI_LABEL_KEYS)[number];
39
+ /** Keys every complete pack defines. */
40
+ export type BookingRequiredLabelKey = (typeof WHO_SCREEN_LABEL_KEYS)[number] | (typeof ADD_CHILD_SHEET_LABEL_KEYS)[number] | (typeof SERVICE_SCREEN_LABEL_KEYS)[number] | (typeof STYLIST_SCREEN_LABEL_KEYS)[number] | (typeof TIME_SCREEN_LABEL_KEYS)[number] | (typeof MANAGE_SCREEN_LABEL_KEYS)[number] | (typeof DETAILS_SCREEN_LABEL_KEYS)[number] | (typeof SUMMARY_BAR_LABEL_KEYS)[number] | (typeof CONFIRMATION_LABEL_KEYS)[number] | (typeof BOOKING_SKELETON_LABEL_KEYS)[number] | (typeof LOGIN_SHEET_LABEL_KEYS)[number] | (typeof PORTAL_SHELL_LABEL_KEYS)[number] | (typeof PORTAL_UNREACHABLE_LABEL_KEYS)[number] | (typeof UPCOMING_BOOKINGS_LABEL_KEYS)[number] | (typeof VISIT_HISTORY_LABEL_KEYS)[number] | (typeof REBOOK_CARDS_LABEL_KEYS)[number] | (typeof CHILD_CARDS_LABEL_KEYS)[number] | (typeof FAMILY_EDITOR_LABEL_KEYS)[number] | (typeof PROFILE_FORM_LABEL_KEYS)[number] | (typeof DATA_CONTROLS_LABEL_KEYS)[number] | (typeof LOGOUT_BUTTON_LABEL_KEYS)[number] | (typeof VIPPS_LINK_ROW_LABEL_KEYS)[number];
41
+ /** A complete label pack for one locale. The opt-in keys may be absent. */
42
+ export type BookingLabels = Record<BookingRequiredLabelKey, BookingLabel> & Partial<Record<BookingOptionalLabelKey, BookingLabel>>;
@@ -15,7 +15,7 @@ import { REBOOK_CARDS_LABEL_KEYS } from './portal/rebook-cards.js';
15
15
  import { UPCOMING_BOOKINGS_LABEL_KEYS } from './portal/upcoming-bookings.js';
16
16
  import { VIPPS_LINK_ROW_LABEL_KEYS } from './portal/vipps-link-row.js';
17
17
  import { VISIT_HISTORY_LABEL_KEYS } from './portal/visit-history.js';
18
- import { SERVICE_SCREEN_LABEL_KEYS } from './service-screen.js';
18
+ import { SERVICE_SCREEN_LABEL_KEYS, SERVICE_SCREEN_MULTI_LABEL_KEYS } from './service-screen.js';
19
19
  import { STYLIST_SCREEN_LABEL_KEYS } from './stylist-screen.js';
20
20
  import { SUMMARY_BAR_LABEL_KEYS } from './summary-bar.js';
21
21
  import { TIME_SCREEN_LABEL_KEYS } from './time-screen.js';
@@ -33,6 +33,7 @@ export const BOOKING_LABEL_KEYS = [
33
33
  ...WHO_SCREEN_LABEL_KEYS,
34
34
  ...ADD_CHILD_SHEET_LABEL_KEYS,
35
35
  ...SERVICE_SCREEN_LABEL_KEYS,
36
+ ...SERVICE_SCREEN_MULTI_LABEL_KEYS,
36
37
  ...STYLIST_SCREEN_LABEL_KEYS,
37
38
  ...TIME_SCREEN_LABEL_KEYS,
38
39
  ...MANAGE_SCREEN_LABEL_KEYS,
@@ -3,13 +3,42 @@
3
3
  *
4
4
  * Every word a booking screen shows arrives through its `labels` prop — meda
5
5
  * ships no default brand and no default language. A screen's labels are a flat
6
- * record of strings keyed `'<screen>.<thing>'` (e.g. `'who.heading'`), so one
7
- * merged pack (`BookingLabels`) can be handed to every screen, and a customer
8
- * can override a single sentence without touching the rest.
6
+ * record keyed `'<screen>.<thing>'` (e.g. `'who.heading'`), so one merged pack
7
+ * (`BookingLabels`) can be handed to every screen, and a customer can override
8
+ * a single sentence without touching the rest.
9
9
  *
10
- * Values are plain strings, so a pack is serialisable (it can travel from a
11
- * server component to the client as a prop). Variable parts are `{name}`
12
- * placeholders, filled by `fillLabel`.
10
+ * A label is a `BookingLabel`: a string, or an array of strings. Both are
11
+ * serialisable, so a pack can travel from a server component to the client as
12
+ * a prop. Variable parts are `{name}` placeholders.
13
+ *
14
+ * The two forms differ only in the DOM they render as element children:
15
+ *
16
+ * - a **string** renders as ONE text node once filled, like a template
17
+ * literal: `'Total {total} · paid at the salon'` → «Total 840 kr · paid at
18
+ * the salon», one node;
19
+ * - an **array** renders one text node per element, each element filled with
20
+ * the same values: `['Total ', '{total}', ' · ', 'paid at the salon']` →
21
+ * four nodes, the same DOM as `Total {total}{' · '}paid at the salon` in JSX.
22
+ * An element that fills to `''` renders nothing.
23
+ *
24
+ * A browser lays text out per text node, so the same sentence split
25
+ * differently can land its later glyphs a sub-pixel apart. The pack decides
26
+ * where a sentence breaks; a screen never splits a string on its own.
27
+ *
28
+ * Text-only targets (`aria-label`, `title`, a file name, an error string) read
29
+ * a label through `fillLabel` / `labelText`, which join an array's elements.
30
+ */
31
+ /** One label: a string (one text node) or one string per text node. */
32
+ export type BookingLabel = string | readonly string[];
33
+ /** A label as plain text: an array's elements joined, a string as it is. */
34
+ export declare function labelText(label: BookingLabel): string;
35
+ /**
36
+ * Replaces `{key}` placeholders in `template`, as plain text. An array is
37
+ * filled element by element and joined. Unknown keys are left as written.
38
+ */
39
+ export declare function fillLabel(template: BookingLabel, values: Readonly<Record<string, string | number>>): string;
40
+ /**
41
+ * The text nodes a label fills to: one for a string, one per element for an
42
+ * array, with empty ones dropped. `[]` when the label fills to nothing.
13
43
  */
14
- /** Replaces `{key}` placeholders in `template`. Unknown keys are left as written. */
15
- export declare function fillLabel(template: string, values: Readonly<Record<string, string | number>>): string;
44
+ export declare function fillLabelPieces(template: BookingLabel, values: Readonly<Record<string, string | number>>): string[];
@@ -3,15 +3,52 @@
3
3
  *
4
4
  * Every word a booking screen shows arrives through its `labels` prop — meda
5
5
  * ships no default brand and no default language. A screen's labels are a flat
6
- * record of strings keyed `'<screen>.<thing>'` (e.g. `'who.heading'`), so one
7
- * merged pack (`BookingLabels`) can be handed to every screen, and a customer
8
- * can override a single sentence without touching the rest.
6
+ * record keyed `'<screen>.<thing>'` (e.g. `'who.heading'`), so one merged pack
7
+ * (`BookingLabels`) can be handed to every screen, and a customer can override
8
+ * a single sentence without touching the rest.
9
9
  *
10
- * Values are plain strings, so a pack is serialisable (it can travel from a
11
- * server component to the client as a prop). Variable parts are `{name}`
12
- * placeholders, filled by `fillLabel`.
10
+ * A label is a `BookingLabel`: a string, or an array of strings. Both are
11
+ * serialisable, so a pack can travel from a server component to the client as
12
+ * a prop. Variable parts are `{name}` placeholders.
13
+ *
14
+ * The two forms differ only in the DOM they render as element children:
15
+ *
16
+ * - a **string** renders as ONE text node once filled, like a template
17
+ * literal: `'Total {total} · paid at the salon'` → «Total 840 kr · paid at
18
+ * the salon», one node;
19
+ * - an **array** renders one text node per element, each element filled with
20
+ * the same values: `['Total ', '{total}', ' · ', 'paid at the salon']` →
21
+ * four nodes, the same DOM as `Total {total}{' · '}paid at the salon` in JSX.
22
+ * An element that fills to `''` renders nothing.
23
+ *
24
+ * A browser lays text out per text node, so the same sentence split
25
+ * differently can land its later glyphs a sub-pixel apart. The pack decides
26
+ * where a sentence breaks; a screen never splits a string on its own.
27
+ *
28
+ * Text-only targets (`aria-label`, `title`, a file name, an error string) read
29
+ * a label through `fillLabel` / `labelText`, which join an array's elements.
13
30
  */
14
- /** Replaces `{key}` placeholders in `template`. Unknown keys are left as written. */
15
- export function fillLabel(template, values) {
31
+ /** A label as plain text: an array's elements joined, a string as it is. */
32
+ export function labelText(label) {
33
+ return typeof label === 'string' ? label : label.join('');
34
+ }
35
+ function fillOne(template, values) {
16
36
  return template.replace(/\{(\w+)\}/g, (match, key) => Object.hasOwn(values, key) ? String(values[key]) : match);
17
37
  }
38
+ /**
39
+ * Replaces `{key}` placeholders in `template`, as plain text. An array is
40
+ * filled element by element and joined. Unknown keys are left as written.
41
+ */
42
+ export function fillLabel(template, values) {
43
+ return typeof template === 'string'
44
+ ? fillOne(template, values)
45
+ : template.map((piece) => fillOne(piece, values)).join('');
46
+ }
47
+ /**
48
+ * The text nodes a label fills to: one for a string, one per element for an
49
+ * array, with empty ones dropped. `[]` when the label fills to nothing.
50
+ */
51
+ export function fillLabelPieces(template, values) {
52
+ const pieces = typeof template === 'string' ? [template] : template;
53
+ return pieces.map((piece) => fillOne(piece, values)).filter((piece) => piece !== '');
54
+ }
@@ -1,4 +1,5 @@
1
1
  import { type Dispatch, type ReactNode, type SetStateAction } from 'react';
2
+ import type { BookingLabel } from './labels.js';
2
3
  import { type OtpSlotsLabels, type OtpSlotsSlot } from './otp-slots.js';
3
4
  import { type SlotClassNames } from './slots.js';
4
5
  import type { BookingGuardian } from './types.js';
@@ -9,7 +10,7 @@ export declare const LOGIN_PANEL_LABEL_KEYS: readonly ["login.heading", "login.c
9
10
  * Placeholders: `login.codeHelp` `{email}`, `login.vippsSentTo` `{to}`,
10
11
  * `login.cooldownWait` `{seconds}`, `login.resendIn` `{time}` («0:30»).
11
12
  */
12
- export type LoginPanelLabels = Record<(typeof LOGIN_PANEL_OWN_LABEL_KEYS)[number], string> & OtpSlotsLabels & VippsButtonLabels;
13
+ export type LoginPanelLabels = Record<(typeof LOGIN_PANEL_OWN_LABEL_KEYS)[number], BookingLabel> & OtpSlotsLabels & VippsButtonLabels;
13
14
  /**
14
15
  * - `root`: the panel's column.
15
16
  * - `notice`: a non-error sentence in the live region (errors use `Field.Error`).
@@ -82,7 +83,7 @@ export interface LoginPanelProps {
82
83
  memory?: LoginPanelMemory;
83
84
  onMemoryChange?: Dispatch<SetStateAction<LoginPanelMemory>>;
84
85
  /** Draws the heading («Log in» / «Check your e-mail»). Default: none — the page has its own. */
85
- heading?: (text: string) => ReactNode;
86
+ heading?: (text: ReactNode) => ReactNode;
86
87
  /** Drawn above the Vipps button on the first screen. */
87
88
  intro?: ReactNode;
88
89
  /** Wait between codes. Default 30 s. */
@@ -211,7 +211,8 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
211
211
  setNotice(null);
212
212
  }
213
213
  const isFieldError = notice !== null && FIELD_ERRORS.has(notice);
214
- const noticeText = notice === null ? null : labels[`login.notice.${notice}`];
214
+ // `null` for a blank notice, so no input is described by an empty one.
215
+ const noticeText = notice === null ? null : renderLabel(labels[`login.notice.${notice}`]);
215
216
  // One live region for the whole flow, ABOVE the switch between the two
216
217
  // screens so it stays mounted across it: a region that arrives already
217
218
  // holding its sentence is often read as nothing at all. It doubles as the
@@ -225,7 +226,7 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
225
226
  : labels['login.cooldownReady'];
226
227
  const cooldownRegion = (_jsx("p", { "aria-live": "polite", className: "sr-only", children: mode === 'code' ? cooldownSentence : null }));
227
228
  const link = slotClass(classNames, 'link', 'font-semibold text-primary underline underline-offset-4 disabled:no-underline disabled:opacity-60');
228
- return (_jsxs("div", { className: slotClass(classNames, 'root', 'space-y-6'), children: [heading(mode === 'start' ? labels['login.heading'] : labels['login.codeHeading']), mode === 'vipps' && (_jsx("p", { className: "text-muted-foreground", children: sentTo === ''
229
+ return (_jsxs("div", { className: slotClass(classNames, 'root', 'space-y-6'), children: [heading(renderLabel(labels[mode === 'start' ? 'login.heading' : 'login.codeHeading'])), mode === 'vipps' && (_jsx("p", { className: "text-muted-foreground", children: sentTo === ''
229
230
  ? labels['login.vippsSentToUnknown']
230
231
  : renderLabel(labels['login.vippsSentTo'], { to: sentTo }) })), liveRegion, cooldownRegion, mode === 'start' ? (_jsxs(_Fragment, { children: [intro, onVipps && (_jsxs(_Fragment, { children: [_jsx(VippsButton, { onVipps: onVipps, next: vippsNext, label: vippsLabel, labels: labels, classNames: vippsClassNames }), _jsxs("div", { className: slotClass(classNames, 'divider', 'flex items-center gap-4 text-sm text-muted-foreground'), "aria-hidden": "true", children: [_jsx("span", { className: "h-px flex-1 bg-border" }), labels['login.or'], _jsx("span", { className: "h-px flex-1 bg-border" })] })] })), _jsx("p", { className: "text-muted-foreground", children: labels['login.emailIntro'] }), _jsxs("form", { onSubmit: submitEmail, className: slotClass(classNames, 'form', 'space-y-6'), children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: emailId, children: labels['login.emailLabel'] }), _jsx(Input, { id: emailId, type: "email", autoComplete: "email", required: true, value: email, "aria-describedby": noticeText ? noticeId : undefined, "aria-invalid": isFieldError || undefined, onChange: (event) => setEmail(event.target.value), className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS) })] }), _jsx(BookingButton, { type: "submit", size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: pending, children: labels['login.sendCode'] })] })] })) : (_jsxs(_Fragment, { children: [_jsxs("form", { onSubmit: submitCode, className: slotClass(classNames, 'form', 'space-y-6'), children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: codeId, children: labels['login.codeLabel'] }), _jsx("div", { ref: codeRef, children: _jsx(OtpSlots, { id: codeId, value: code,
231
232
  // Read-only rather than disabled while a request is out, so
@@ -1,10 +1,11 @@
1
+ import { type BookingLabel } from './labels.js';
1
2
  import { type LoginPanelLabels, type LoginPanelProps } from './login-panel.js';
2
3
  import { type SlotClassNames } from './slots.js';
3
4
  import type { BookingGuardian } from './types.js';
4
5
  export declare const LOGIN_SHEET_OWN_LABEL_KEYS: readonly ["loginSheet.prompt", "loginSheet.trigger", "loginSheet.intro", "loginSheet.vipps", "loginSheet.close"];
5
6
  export declare const LOGIN_SHEET_LABEL_KEYS: readonly ["loginSheet.prompt", "loginSheet.trigger", "loginSheet.intro", "loginSheet.vipps", "loginSheet.close", "login.heading", "login.codeHeading", "login.or", "login.emailIntro", "login.emailLabel", "login.sendCode", "login.codeLabel", "login.submitCode", "login.codeHelp", "login.codeHelpVipps", "login.vippsSentTo", "login.vippsSentToUnknown", "login.cooldownWait", "login.cooldownReady", "login.resendIn", "login.resend", "login.switchEmail", "login.useEmailInstead", "login.notice.sent", "login.notice.resent", "login.notice.unreachable", "login.notice.badEmail", "login.notice.invalid", "login.notice.throttled", "login.notice.vippsInvalid", "login.notice.vippsConflict", "otp.label", "vipps.button", "vipps.unavailable", "vipps.throttled"];
6
7
  /** `loginSheet.prompt` holds a `{trigger}` placeholder where the trigger button goes. */
7
- export type LoginSheetLabels = Record<(typeof LOGIN_SHEET_OWN_LABEL_KEYS)[number], string> & LoginPanelLabels;
8
+ export type LoginSheetLabels = Record<(typeof LOGIN_SHEET_OWN_LABEL_KEYS)[number], BookingLabel> & LoginPanelLabels;
8
9
  /**
9
10
  * - `prompt`: the «have an account? Log in» row.
10
11
  * - `trigger`: the button inside it.
@@ -5,6 +5,7 @@ import { useState } from 'react';
5
5
  import { Sheet } from '../primitives/sheet.js';
6
6
  import { labelParts } from './internal/label-parts.js';
7
7
  import { BookingButton } from './internal/ui.js';
8
+ import { labelText } from './labels.js';
8
9
  import { LOGIN_PANEL_LABEL_KEYS, LoginPanel, loginMemoryFor, } from './login-panel.js';
9
10
  import { slotClass } from './slots.js';
10
11
  export const LOGIN_SHEET_OWN_LABEL_KEYS = [
@@ -41,7 +42,7 @@ export function LoginSheet({ labels, onStartLogin, onVerify, onVipps, onSignedIn
41
42
  const [signedIn, setSignedIn] = useState(false);
42
43
  return (_jsxs(Sheet, { open: open, onOpenChange: setOpen, children: [trigger && !signedIn && (_jsx("p", { className: slotClass(classNames, 'prompt', 'text-sm text-muted-foreground'), children: labelParts(labels['loginSheet.prompt'], {
43
44
  trigger: (_jsx(Sheet.Trigger, { className: slotClass(classNames, 'trigger', 'font-semibold text-primary underline underline-offset-4'), children: labels['loginSheet.trigger'] })),
44
- }) })), _jsxs(Sheet.Content, { className: slotClass(classNames, 'content', 'z-50'), children: [_jsx(LoginPanel, { labels: labels, memory: memory, onMemoryChange: setMemory, onStartLogin: onStartLogin, onVerify: onVerify, onVipps: onVipps, vippsNext: resumePath, vippsLabel: labels['loginSheet.vipps'], resendCooldownMs: resendCooldownMs, classNames: panelClassNames, otpClassNames: otpClassNames, vippsClassNames: vippsClassNames, onSignedIn: (guardian) => {
45
+ }) })), _jsxs(Sheet.Content, { className: slotClass(classNames, 'content', 'z-50'), children: [_jsx(LoginPanel, { labels: labels, memory: memory, onMemoryChange: setMemory, onStartLogin: onStartLogin, onVerify: onVerify, onVipps: onVipps, vippsNext: resumePath, vippsLabel: labelText(labels['loginSheet.vipps']), resendCooldownMs: resendCooldownMs, classNames: panelClassNames, otpClassNames: otpClassNames, vippsClassNames: vippsClassNames, onSignedIn: (guardian) => {
45
46
  setSignedIn(true);
46
47
  setOpen(false);
47
48
  onSignedIn(guardian);
@@ -1,4 +1,5 @@
1
1
  import type { BookingDaypart, BookingFormat } from './format.js';
2
+ import { type BookingLabel } from './labels.js';
2
3
  import { type SlotClassNames } from './slots.js';
3
4
  import { type TimeScreenComponents, type TimeScreenLabels, type TimeScreenSlot } from './time-screen.js';
4
5
  import type { BookingDayDto, BookingManageDto, BookingSlotDto } from './types.js';
@@ -33,7 +34,7 @@ export declare const MANAGE_SCREEN_LABEL_KEYS: readonly ["manage.heading", "mana
33
34
  * The three `cancel.reason.*` strings are both the chips' text and the value
34
35
  * `onCancel` receives.
35
36
  */
36
- export type ManageScreenLabels = Record<(typeof MANAGE_SCREEN_LABEL_KEYS)[number], string> & TimeScreenLabels;
37
+ export type ManageScreenLabels = Record<(typeof MANAGE_SCREEN_LABEL_KEYS)[number], BookingLabel> & TimeScreenLabels;
37
38
  /** Why a mutation failed. Map anything the route did not name to `unavailable`. */
38
39
  export type ManageErrorCode = 'windowPassed' | 'slotTaken' | 'notFound' | 'conflict' | 'invalidInput' | 'unavailable';
39
40
  /**
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
4
4
  import { cn } from '../lib/utils.js';
5
5
  import { renderLabel } from './internal/label-parts.js';
6
6
  import { BookingButton } from './internal/ui.js';
7
- import { fillLabel } from './labels.js';
7
+ import { fillLabel, labelText } from './labels.js';
8
8
  import { slotClass } from './slots.js';
9
9
  import { TimeScreen, } from './time-screen.js';
10
10
  /**
@@ -121,7 +121,7 @@ const LINK_CLASS = 'font-semibold text-primary underline underline-offset-4';
121
121
  /** «Name's appointment» when a name was given, the bare noun when not. */
122
122
  function theirAppointment(labels, name) {
123
123
  if (!name)
124
- return labels['manage.appointment.unnamed'];
124
+ return labelText(labels['manage.appointment.unnamed']);
125
125
  return fillLabel(labels[/[sxz]$/i.test(name) ? 'manage.appointment.namedSibilant' : 'manage.appointment.named'], { name });
126
126
  }
127
127
  /**
@@ -142,16 +142,20 @@ function remainingLabel(labels, ms) {
142
142
  return fillLabel(labels['manage.remaining.in'], { amount: hourPart });
143
143
  const minutes = Math.floor((ms % HOUR_MS) / 60_000);
144
144
  if (minutes < 1)
145
- return labels['manage.remaining.underMinute'];
145
+ return labelText(labels['manage.remaining.underMinute']);
146
146
  return fillLabel(labels['manage.remaining.in'], {
147
147
  amount: fillLabel(labels[minutes === 1 ? 'manage.remaining.minute.one' : 'manage.remaining.minute.other'], { count: minutes }),
148
148
  });
149
149
  }
150
150
  function ringUs(labels, phone) {
151
- return phone ? fillLabel(labels['manage.ringUsOn'], { phone }) : labels['manage.ringUs'];
151
+ return phone
152
+ ? fillLabel(labels['manage.ringUsOn'], { phone })
153
+ : labelText(labels['manage.ringUs']);
152
154
  }
153
155
  function callUs(labels, phone) {
154
- return phone ? fillLabel(labels['manage.callUsOn'], { phone }) : labels['manage.callUs'];
156
+ return phone
157
+ ? fillLabel(labels['manage.callUsOn'], { phone })
158
+ : labelText(labels['manage.callUs']);
155
159
  }
156
160
  /** What the visitor is told went wrong — never the upstream's own words. */
157
161
  function errorSentence(labels, code, phone) {
@@ -159,13 +163,13 @@ function errorSentence(labels, code, phone) {
159
163
  case 'windowPassed':
160
164
  return fillLabel(labels['manage.error.windowPassed'], { ringUs: ringUs(labels, phone) });
161
165
  case 'slotTaken':
162
- return labels['manage.error.slotTaken'];
166
+ return labelText(labels['manage.error.slotTaken']);
163
167
  case 'notFound':
164
168
  return fillLabel(labels['manage.error.notFound'], { callUs: callUs(labels, phone) });
165
169
  case 'conflict':
166
170
  return fillLabel(labels['manage.error.conflict'], { ringUs: ringUs(labels, phone) });
167
171
  case 'invalidInput':
168
- return labels['manage.error.invalidInput'];
172
+ return labelText(labels['manage.error.invalidInput']);
169
173
  default:
170
174
  return fillLabel(labels['manage.error.unavailable'], { ringUs: ringUs(labels, phone) });
171
175
  }
@@ -200,7 +204,7 @@ export function ManageScreen({ labels, format, dayparts, booking, phone = null,
200
204
  ? null
201
205
  : format.stylistName(booking.resourceName) || null;
202
206
  const serviceLabel = booking.serviceName === UNKNOWN_SERVICE
203
- ? labels['manage.serviceFallback']
207
+ ? labelText(labels['manage.serviceFallback'])
204
208
  : booking.serviceName;
205
209
  // The LARGER window wins: the sentence promises both actions, and the earlier
206
210
  // of the two deadlines is the one true of both.
@@ -330,7 +334,7 @@ function ClosedBooking({ labels, format, status, bookingHref, phone, className,
330
334
  * contiguous, which the sub-line says.
331
335
  */
332
336
  function PartyChoice({ labels, format, bookedForName, phone, onJustThisOne, onBack, className, }) {
333
- const who = bookedForName ?? labels['manage.party.unnamed'];
337
+ const who = bookedForName ?? labelText(labels['manage.party.unnamed']);
334
338
  return (_jsxs("div", { className: className, children: [_jsx("h2", { className: "font-sans text-lg font-bold", children: renderLabel(labels['manage.party.heading'], { who }) }), _jsxs("div", { className: "flex flex-wrap items-center gap-4", children: [_jsx(BookingButton, { size: "lg", onClick: onJustThisOne, children: renderLabel(labels['manage.party.justThisOne'], { who }) }), _jsx(BookingButton, { size: "lg", variant: "outline", onClick: onBack, children: labels['manage.back'] })] }), _jsxs("p", { className: "text-sm text-muted-foreground", children: [labels['manage.party.note'], ' ', _jsx(PhoneSentence, { format: format, phone: phone, children: ringUs(labels, phone) }), labels['manage.party.noteSuffix']] })] }));
335
339
  }
336
340
  /**
@@ -362,11 +366,12 @@ function ConfirmCancel({ labels, format, booking, reason, busy, onPickReason, on
362
366
  dateTime: format.clock.dateTime(booking.startTs),
363
367
  hours: booking.cancelWindowHours,
364
368
  }) }), _jsxs("fieldset", { className: "flex flex-wrap gap-2", children: [_jsx("legend", { className: "sr-only", children: labels['manage.cancel.reasonLegend'] }), CANCEL_REASON_KEYS.map((key) => {
365
- const option = labels[key];
369
+ // The reason travels as text; the chip shows the label in its own pieces.
370
+ const option = labelText(labels[key]);
366
371
  const picked = reason === option;
367
372
  return (_jsx(BookingButton, { variant: "outline", size: "sm", "aria-pressed": picked,
368
373
  // Tapping the chosen chip again clears it: the answer is
369
374
  // optional, so there has to be a way back to having given none.
370
- onClick: () => onPickReason(picked ? null : option), className: cn('rounded-full', picked ? 'border-primary ring-2 ring-primary' : ''), children: option }, key));
375
+ onClick: () => onPickReason(picked ? null : option), className: cn('rounded-full', picked ? 'border-primary ring-2 ring-primary' : ''), children: labels[key] }, key));
371
376
  })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-4", children: [_jsx(BookingButton, { size: "lg", disabled: busy, onClick: onConfirm, children: labels['manage.cancel.confirm'] }), _jsx(BookingButton, { size: "lg", variant: "outline", disabled: busy, onClick: onBack, children: labels['manage.cancel.keep'] })] })] }));
372
377
  }
@@ -1,6 +1,7 @@
1
+ import { type BookingLabel } from './labels.js';
1
2
  import { type SlotClassNames } from './slots.js';
2
3
  export declare const OTP_SLOTS_LABEL_KEYS: readonly ["otp.label"];
3
- export type OtpSlotsLabels = Record<(typeof OTP_SLOTS_LABEL_KEYS)[number], string>;
4
+ export type OtpSlotsLabels = Record<(typeof OTP_SLOTS_LABEL_KEYS)[number], BookingLabel>;
4
5
  /**
5
6
  * - `root`: the positioned wrapper.
6
7
  * - `row`: the six-box grid.
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId, useLayoutEffect, useRef, useState, } from 'react';
4
4
  import { cn } from '../lib/utils.js';
5
+ import { labelText } from './labels.js';
5
6
  import { slotClass } from './slots.js';
6
7
  const LENGTH = 6;
7
8
  function digitsOnly(value) {
@@ -98,7 +99,7 @@ export function OtpSlots({ id, name = 'code', value, disabled = false, readOnly
98
99
  return (_jsxs("div", { className: slotClass(classNames, 'root', 'relative'), children: [_jsx("input", { ref: hiddenRef, id: fieldId, name: name, inputMode: "numeric", autoComplete: "one-time-code", autoCorrect: "off", spellCheck: false,
99
100
  // `\d*` rather than `[0-9]{6}`: it is the spelling iOS reads as «show the
100
101
  // number pad», and the length is `minLength`/`maxLength`'s job.
101
- pattern: "\\d*", minLength: LENGTH, maxLength: LENGTH, required: true, disabled: disabled, readOnly: readOnly, "aria-busy": readOnly || undefined, value: digits, "aria-invalid": invalid || undefined, "aria-describedby": describedBy, "aria-label": labels['otp.label'], onChange: handleChange, onKeyDown: handleKeyDown, onKeyUp: (event) => syncCaret(event.currentTarget), onSelect: (event) => syncCaret(event.currentTarget), onClick: (event) => syncCaret(event.currentTarget), onPaste: handlePaste, className: "absolute inset-0 z-10 h-full w-full cursor-text opacity-0" }), _jsx("div", { "aria-hidden": "true", "data-slot": "otp-row", className: slotClass(classNames, 'row', 'grid grid-cols-6 gap-1.5 sm:gap-2.5'), onMouseDown: (event) => {
102
+ pattern: "\\d*", minLength: LENGTH, maxLength: LENGTH, required: true, disabled: disabled, readOnly: readOnly, "aria-busy": readOnly || undefined, value: digits, "aria-invalid": invalid || undefined, "aria-describedby": describedBy, "aria-label": labelText(labels['otp.label']), onChange: handleChange, onKeyDown: handleKeyDown, onKeyUp: (event) => syncCaret(event.currentTarget), onSelect: (event) => syncCaret(event.currentTarget), onClick: (event) => syncCaret(event.currentTarget), onPaste: handlePaste, className: "absolute inset-0 z-10 h-full w-full cursor-text opacity-0" }), _jsx("div", { "aria-hidden": "true", "data-slot": "otp-row", className: slotClass(classNames, 'row', 'grid grid-cols-6 gap-1.5 sm:gap-2.5'), onMouseDown: (event) => {
102
103
  event.preventDefault();
103
104
  focusHidden();
104
105
  }, children: Array.from({ length: LENGTH }, (_, index) => {
@@ -1,7 +1,8 @@
1
+ import type { BookingLabel } from '../labels.js';
1
2
  import { type SlotClassNames } from '../slots.js';
2
3
  export declare const AGE_CONFIRM_CARD_LABEL_KEYS: readonly ["ageConfirm.heading", "ageConfirm.lead", "ageConfirm.legend", "ageConfirm.birthYear", "ageConfirm.birthMonth", "ageConfirm.birthMonthUnknown", "ageConfirm.confirm", "ageConfirm.dismiss"];
3
4
  /** `heading` and `legend` take `{name}`. */
4
- export type AgeConfirmCardLabels = Record<(typeof AGE_CONFIRM_CARD_LABEL_KEYS)[number], string>;
5
+ export type AgeConfirmCardLabels = Record<(typeof AGE_CONFIRM_CARD_LABEL_KEYS)[number], BookingLabel>;
5
6
  /** `root` (the section), `heading`, `form`, `actions` (the button row). */
6
7
  export type AgeConfirmCardSlot = 'root' | 'heading' | 'form' | 'actions';
7
8
  export interface AgeConfirmCardProps {
@@ -1,5 +1,6 @@
1
1
  import type { ComponentType, ReactNode } from 'react';
2
2
  import type { BookingFormat } from '../format.js';
3
+ import type { BookingLabel } from '../labels.js';
3
4
  import { type SlotClassNames } from '../slots.js';
4
5
  import type { ChildSummary } from '../types.js';
5
6
  export declare const CHILD_CARDS_LABEL_KEYS: readonly ["childCards.empty", "childCards.neverVisited", "childCards.lastVisit", "childCards.lastVisitNoService", "childCards.book", "childCards.stylist", "childCards.next", "childCards.none"];
@@ -8,7 +9,7 @@ export declare const CHILD_CARDS_LABEL_KEYS: readonly ["childCards.empty", "chil
8
9
  * `{age}` `{service}` `{date}`; `childCards.lastVisitNoService` `{age}`
9
10
  * `{date}`; `childCards.book` `{name}`.
10
11
  */
11
- export type ChildCardsLabels = Record<(typeof CHILD_CARDS_LABEL_KEYS)[number], string>;
12
+ export type ChildCardsLabels = Record<(typeof CHILD_CARDS_LABEL_KEYS)[number], BookingLabel>;
12
13
  /**
13
14
  * - `list`: the grid. - `card`: one child. - `avatar`: the initial.
14
15
  * - `name`, `detail`: the two lines. - `details`: the full card's facts.
@@ -1,3 +1,4 @@
1
+ import { type BookingLabel } from '../labels.js';
1
2
  import { type SlotClassNames } from '../slots.js';
2
3
  import { type PortalActionFailure, type PortalActionResult } from './action-result.js';
3
4
  export declare const DATA_CONTROLS_LABEL_KEYS: readonly ["dataControls.heading", "dataControls.export", "dataControls.delete", "dataControls.confirmHeading", "dataControls.consequences", "dataControls.confirmLabel", "dataControls.confirmHelp", "dataControls.wrongWord", "dataControls.deleteForever", "dataControls.cancel", "dataControls.unreachable", "dataControls.confirmWord"];
@@ -5,7 +6,7 @@ export declare const DATA_CONTROLS_LABEL_KEYS: readonly ["dataControls.heading",
5
6
  * `confirmWord` is the literal the visitor must type (e.g. «DELETE»);
6
7
  * `confirmLabel` and `wrongWord` take `{word}`.
7
8
  */
8
- export type DataControlsLabels = Record<(typeof DATA_CONTROLS_LABEL_KEYS)[number], string>;
9
+ export type DataControlsLabels = Record<(typeof DATA_CONTROLS_LABEL_KEYS)[number], BookingLabel>;
9
10
  /**
10
11
  * `root` (the section), `heading`, `actions` (the export/delete row),
11
12
  * `confirm` (the confirm panel form).