@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.
- package/README.md +35 -1
- package/dist/booking/add-child-sheet.d.ts +2 -1
- package/dist/booking/add-child-sheet.js +2 -1
- package/dist/booking/booking-skeleton.d.ts +2 -1
- package/dist/booking/confirmation.d.ts +2 -1
- package/dist/booking/confirmation.js +7 -6
- package/dist/booking/details-screen.d.ts +5 -1
- package/dist/booking/details-screen.js +13 -8
- package/dist/booking/index.d.ts +2 -2
- package/dist/booking/index.js +2 -2
- package/dist/booking/internal/label-parts.d.ts +36 -21
- package/dist/booking/internal/label-parts.js +71 -43
- package/dist/booking/internal/service-multi.d.ts +55 -0
- package/dist/booking/internal/service-multi.js +83 -0
- package/dist/booking/internal/visit.d.ts +13 -0
- package/dist/booking/internal/visit.js +10 -0
- package/dist/booking/label-keys.d.ts +13 -5
- package/dist/booking/label-keys.js +2 -1
- package/dist/booking/labels.d.ts +37 -8
- package/dist/booking/labels.js +45 -8
- package/dist/booking/login-panel.d.ts +3 -2
- package/dist/booking/login-panel.js +3 -2
- package/dist/booking/login-sheet.d.ts +2 -1
- package/dist/booking/login-sheet.js +2 -1
- package/dist/booking/manage-screen.d.ts +2 -1
- package/dist/booking/manage-screen.js +16 -11
- package/dist/booking/otp-slots.d.ts +2 -1
- package/dist/booking/otp-slots.js +2 -1
- package/dist/booking/portal/age-confirm-card.d.ts +2 -1
- package/dist/booking/portal/child-cards.d.ts +2 -1
- package/dist/booking/portal/data-controls.d.ts +2 -1
- package/dist/booking/portal/data-controls.js +3 -3
- package/dist/booking/portal/family-editor.d.ts +2 -1
- package/dist/booking/portal/family-editor.js +9 -9
- package/dist/booking/portal/logout-button.d.ts +2 -1
- package/dist/booking/portal/logout-button.js +2 -1
- package/dist/booking/portal/portal-shell.d.ts +2 -1
- package/dist/booking/portal/portal-shell.js +3 -2
- package/dist/booking/portal/portal-unreachable.d.ts +2 -1
- package/dist/booking/portal/profile-form.d.ts +2 -1
- package/dist/booking/portal/profile-form.js +3 -2
- package/dist/booking/portal/rebook-cards.d.ts +2 -1
- package/dist/booking/portal/rebook-cards.js +3 -2
- package/dist/booking/portal/upcoming-bookings.d.ts +2 -1
- package/dist/booking/portal/upcoming-bookings.js +3 -3
- package/dist/booking/portal/vipps-link-row.d.ts +2 -1
- package/dist/booking/portal/vipps-link-row.js +7 -6
- package/dist/booking/portal/visit-history.d.ts +2 -1
- package/dist/booking/service-screen.d.ts +78 -3
- package/dist/booking/service-screen.js +131 -46
- package/dist/booking/stylist-screen.d.ts +2 -1
- package/dist/booking/stylist-screen.js +7 -6
- package/dist/booking/summary-bar.d.ts +2 -1
- package/dist/booking/time-screen.d.ts +2 -1
- package/dist/booking/time-screen.js +42 -19
- package/dist/booking/types.d.ts +14 -1
- package/dist/booking/vipps-button.d.ts +2 -1
- package/dist/booking/vipps-button.js +2 -1
- package/dist/booking/who-screen.d.ts +2 -1
- package/dist/booking/who-screen.js +4 -3
- package/package.json +1 -1
- 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 =
|
|
33
|
-
/**
|
|
34
|
-
|
|
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,
|
package/dist/booking/labels.d.ts
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
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[];
|
package/dist/booking/labels.js
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
/**
|
|
15
|
-
export function
|
|
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],
|
|
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:
|
|
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
|
-
|
|
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' ?
|
|
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],
|
|
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],
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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],
|
|
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],
|
|
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],
|
|
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],
|
|
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).
|