@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
package/README.md
CHANGED
|
@@ -81,13 +81,47 @@ ships no default language and no brand. Override, cheapest first:
|
|
|
81
81
|
1. **CSS variables** — the shadcn tokens the bridge maps (`--primary`, `--card`,
|
|
82
82
|
`--muted`, `--border`, `--ring`, `--radius`, …). Colours come from nowhere else.
|
|
83
83
|
2. **`labels`** — a flat record (`'who.heading'`, …); `BookingLabels` is the full set
|
|
84
|
-
and `BOOKING_LABEL_KEYS` lists it. `{placeholders}` are filled by the screen.
|
|
84
|
+
and `BOOKING_LABEL_KEYS` lists it. `{placeholders}` are filled by the screen. A
|
|
85
|
+
value is a `BookingLabel`: a **string** renders as one text node once filled (like
|
|
86
|
+
a template literal), a **string array** renders one text node per element, each
|
|
87
|
+
element filled with the same values (see below).
|
|
85
88
|
3. **`classNames`** — per documented slot (`root`, `card`, `cardSelected`, `chip`, …),
|
|
86
89
|
merged after the defaults so an override wins.
|
|
87
90
|
4. **`components`** — card renderers (`ServiceCard`, `StylistCard`, `TimeChip`,
|
|
88
91
|
`DayChip`, `PersonCard`, …). The defaults are exported (`DefaultServiceCard`, …)
|
|
89
92
|
so an override can wrap them.
|
|
90
93
|
|
|
94
|
+
### Where a sentence breaks into text nodes
|
|
95
|
+
|
|
96
|
+
A browser lays text out per DOM text node, so the same sentence split differently can
|
|
97
|
+
land its later glyphs a sub-pixel apart. The pack decides; a screen never splits a
|
|
98
|
+
string on its own:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
// One text node: «Total 840 kr · paid at the salon».
|
|
102
|
+
const asOneNode = {
|
|
103
|
+
'confirmation.party.total': 'Total {total} · paid at the salon',
|
|
104
|
+
} satisfies Partial<BookingLabels>;
|
|
105
|
+
|
|
106
|
+
// Four text nodes, the DOM of `Total {total}{' · '}paid at the salon` in JSX.
|
|
107
|
+
const asPieces = {
|
|
108
|
+
'confirmation.party.total': ['Total ', '{total}', ' · ', 'paid at the salon'],
|
|
109
|
+
} satisfies Partial<BookingLabels>;
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- An element that fills to `''` renders nothing; a label that fills to nothing renders
|
|
113
|
+
nothing at all.
|
|
114
|
+
- Lines joined from several parts (the confirmation card's `service · stylist · when ·
|
|
115
|
+
price`, the portal's booking lines) are one text node while every part is a string,
|
|
116
|
+
and in pieces (each part and separator its own node) once one of their labels is an
|
|
117
|
+
array. `joinLabelParts(parts, separator, { pieces })` forces either.
|
|
118
|
+
- Text-only targets — `aria-label`, `placeholder`, file names, error and status
|
|
119
|
+
messages, live-region announcements, a value filled into another label, and a custom
|
|
120
|
+
component's `string` prop — read the label flattened (`fillLabel`, `labelText`).
|
|
121
|
+
- Helpers, for UI composed around the screens: `renderLabel(label, values)` (element
|
|
122
|
+
children), `fillLabel(label, values)` (text), `labelParts(label, { hole: <el/> })`
|
|
123
|
+
(element holes), `fillLabelPieces`, `labelText`, `joinLabelParts`.
|
|
124
|
+
|
|
91
125
|
```css
|
|
92
126
|
@import 'tailwindcss';
|
|
93
127
|
@import '@medalsocial/meda/styles/bridge.css'; /* your own shadcn tokens */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BookingFormat } from './format.js';
|
|
2
|
+
import { type BookingLabel } from './labels.js';
|
|
2
3
|
import { type SlotClassNames } from './slots.js';
|
|
3
4
|
import type { NewChild, SaveResult } from './types.js';
|
|
4
5
|
/**
|
|
@@ -13,7 +14,7 @@ import type { NewChild, SaveResult } from './types.js';
|
|
|
13
14
|
* save — so a child is never shown as added before it exists.
|
|
14
15
|
*/
|
|
15
16
|
export declare const ADD_CHILD_SHEET_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"];
|
|
16
|
-
export type AddChildSheetLabels = Record<(typeof ADD_CHILD_SHEET_LABEL_KEYS)[number],
|
|
17
|
+
export type AddChildSheetLabels = Record<(typeof ADD_CHILD_SHEET_LABEL_KEYS)[number], BookingLabel>;
|
|
17
18
|
/**
|
|
18
19
|
* - `root` — the dashed trigger card (the only element in the page flow)
|
|
19
20
|
* - `content` — the sheet (`<dialog>`)
|
|
@@ -7,6 +7,7 @@ import { Input } from '../primitives/input.js';
|
|
|
7
7
|
import { Sheet } from '../primitives/sheet.js';
|
|
8
8
|
import { Textarea } from '../primitives/textarea.js';
|
|
9
9
|
import { BOOKING_INPUT_CLASS, BOOKING_SELECT_CLASS, BOOKING_TEXTAREA_CLASS, BookingButton, } from './internal/ui.js';
|
|
10
|
+
import { labelText } from './labels.js';
|
|
10
11
|
import { slotClass } from './slots.js';
|
|
11
12
|
/**
|
|
12
13
|
* «+ Add child» — a dashed card that opens a small sheet: name, birth year,
|
|
@@ -58,7 +59,7 @@ export function AddChildSheet({ labels, format, saveNotes, disabled, onSave, cur
|
|
|
58
59
|
event.preventDefault();
|
|
59
60
|
const trimmed = name.trim();
|
|
60
61
|
if (trimmed === '' || year === '') {
|
|
61
|
-
setError(labels['addChild.missing']);
|
|
62
|
+
setError(labelText(labels['addChild.missing']));
|
|
62
63
|
return;
|
|
63
64
|
}
|
|
64
65
|
startTransition(async () => {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import type { BookingLabel } from './labels.js';
|
|
1
2
|
import { type SlotClassNames } from './slots.js';
|
|
2
3
|
export declare const BOOKING_SKELETON_LABEL_KEYS: readonly ["bookingSkeleton.opening"];
|
|
3
4
|
/** Copy for the pending skeleton: the sentence a screen reader hears while it is up. */
|
|
4
|
-
export type BookingSkeletonLabels = Record<(typeof BOOKING_SKELETON_LABEL_KEYS)[number],
|
|
5
|
+
export type BookingSkeletonLabels = Record<(typeof BOOKING_SKELETON_LABEL_KEYS)[number], BookingLabel>;
|
|
5
6
|
/**
|
|
6
7
|
* `classNames` slots: `root` the fixed overlay · `frame` the pulsing column ·
|
|
7
8
|
* `bar` the active step-bar segment · `chip` one of the four person chips.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ComponentType } from 'react';
|
|
2
2
|
import type { BookingFormat } from './format.js';
|
|
3
|
+
import { type BookingLabel } from './labels.js';
|
|
3
4
|
import { type SlotClassNames } from './slots.js';
|
|
4
5
|
import type { WizardItem } from './types.js';
|
|
5
6
|
/**
|
|
@@ -14,7 +15,7 @@ import type { WizardItem } from './types.js';
|
|
|
14
15
|
* link to a page that 404s reads as a booking that did not quite happen.
|
|
15
16
|
*/
|
|
16
17
|
export declare const CONFIRMATION_LABEL_KEYS: readonly ["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"];
|
|
17
|
-
export type ConfirmationLabels = Record<(typeof CONFIRMATION_LABEL_KEYS)[number],
|
|
18
|
+
export type ConfirmationLabels = Record<(typeof CONFIRMATION_LABEL_KEYS)[number], BookingLabel>;
|
|
18
19
|
/**
|
|
19
20
|
* One booked line (one person's appointment). One array rather than parallel
|
|
20
21
|
* lists, because every part answers about the SAME line.
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { joinLabelParts, renderLabel } from './internal/label-parts.js';
|
|
4
4
|
import { BookingButton } from './internal/ui.js';
|
|
5
|
-
import {
|
|
5
|
+
import { visitName } from './internal/visit.js';
|
|
6
|
+
import { fillLabel, labelText } from './labels.js';
|
|
6
7
|
import { slotClass } from './slots.js';
|
|
7
8
|
/**
|
|
8
9
|
* The confirmation screen — the one the whole flow exists to reach.
|
|
@@ -54,10 +55,10 @@ export function Confirmation({ lines: rawLines, startTs, totalOre, address = nul
|
|
|
54
55
|
const singleCard = first ? (_jsx("p", { className: slotClass(classNames, 'card', 'rounded-lg border border-border bg-card px-5 py-4 font-medium'), children: joinLabelParts([
|
|
55
56
|
first.item.bookedForName
|
|
56
57
|
? renderLabel(labels['confirmation.serviceFor'], {
|
|
57
|
-
service: first.item
|
|
58
|
+
service: visitName(first.item),
|
|
58
59
|
name: first.item.bookedForName,
|
|
59
60
|
})
|
|
60
|
-
: first.item
|
|
61
|
+
: visitName(first.item),
|
|
61
62
|
first.stylistName,
|
|
62
63
|
renderLabel(labels['confirmation.when'], {
|
|
63
64
|
day: clock.dayLabel(startTs, now),
|
|
@@ -82,7 +83,7 @@ function PartyCard({ lines, totalOre, now, format, labels, classNames, PartyLine
|
|
|
82
83
|
const text = fillLabel(labels['confirmation.party.line'], {
|
|
83
84
|
time,
|
|
84
85
|
name: line.item.bookedForName ?? '',
|
|
85
|
-
service: line.item
|
|
86
|
+
service: visitName(line.item),
|
|
86
87
|
stylist: line.stylistName
|
|
87
88
|
? fillLabel(labels['confirmation.party.stylist'], { name: line.stylistName })
|
|
88
89
|
: '',
|
|
@@ -94,8 +95,8 @@ function PartyCard({ lines, totalOre, now, format, labels, classNames, PartyLine
|
|
|
94
95
|
.trim();
|
|
95
96
|
return (_jsx(PartyLine
|
|
96
97
|
// biome-ignore lint/suspicious/noArrayIndexKey: two lines can share a service; the index is the identity
|
|
97
|
-
, { line: line, text: text, manageLabel: labels['confirmation.manage'], manageAriaLabel: fillLabel(labels['confirmation.manageFor'], {
|
|
98
|
-
who: line.item.bookedForName ?? line.item
|
|
98
|
+
, { line: line, text: text, manageLabel: labelText(labels['confirmation.manage']), manageAriaLabel: fillLabel(labels['confirmation.manageFor'], {
|
|
99
|
+
who: line.item.bookedForName ?? visitName(line.item),
|
|
99
100
|
time,
|
|
100
101
|
}), linkClassName: slotClass(classNames, 'link', 'text-sm font-semibold text-primary underline underline-offset-4') }, index));
|
|
101
102
|
}) }), _jsx("p", { className: "font-semibold", children: renderLabel(labels['confirmation.party.total'], { total: format.price(totalOre) }) })] }));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ComponentType } from 'react';
|
|
2
2
|
import type { BookingFormat } from './format.js';
|
|
3
|
+
import { type BookingLabel } from './labels.js';
|
|
3
4
|
import { type SlotClassNames } from './slots.js';
|
|
4
5
|
import type { BookingFamilyMember, WizardAction, WizardError, WizardState } from './types.js';
|
|
5
6
|
/**
|
|
@@ -14,7 +15,7 @@ import type { BookingFamilyMember, WizardAction, WizardError, WizardState } from
|
|
|
14
15
|
*/
|
|
15
16
|
export declare const DETAILS_SCREEN_LABEL_KEYS: readonly ["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"];
|
|
16
17
|
export type DetailsScreenLabelKey = (typeof DETAILS_SCREEN_LABEL_KEYS)[number];
|
|
17
|
-
export type DetailsScreenLabels = Record<DetailsScreenLabelKey,
|
|
18
|
+
export type DetailsScreenLabels = Record<DetailsScreenLabelKey, BookingLabel>;
|
|
18
19
|
/**
|
|
19
20
|
* The label key for each failure the wizard can carry. Exported because some
|
|
20
21
|
* of them (e.g. `maxParty`) are raised on earlier steps, where the shell shows
|
|
@@ -23,7 +24,10 @@ export type DetailsScreenLabels = Record<DetailsScreenLabelKey, string>;
|
|
|
23
24
|
export declare const DETAILS_ERROR_LABEL_KEYS: Record<WizardError, DetailsScreenLabelKey>;
|
|
24
25
|
/** One line of the submission. Optional fields are ABSENT, never blank. */
|
|
25
26
|
export interface BookingSubmissionItem {
|
|
27
|
+
/** The person's first service. */
|
|
26
28
|
serviceId: string;
|
|
29
|
+
/** The services after the first, in order, in the same visit. ABSENT for a one-service visit. */
|
|
30
|
+
extraServiceIds?: string[];
|
|
27
31
|
resourceId?: string;
|
|
28
32
|
startTs: number;
|
|
29
33
|
bookedForName?: string;
|
|
@@ -7,7 +7,8 @@ import { Input } from '../primitives/input.js';
|
|
|
7
7
|
import { Textarea } from '../primitives/textarea.js';
|
|
8
8
|
import { renderLabel } from './internal/label-parts.js';
|
|
9
9
|
import { BOOKING_CHECKBOX_CLASS, BOOKING_INPUT_CLASS, BOOKING_TEXTAREA_CLASS, BookingButton, } from './internal/ui.js';
|
|
10
|
-
import {
|
|
10
|
+
import { visitName } from './internal/visit.js';
|
|
11
|
+
import { fillLabel, labelText } from './labels.js';
|
|
11
12
|
import { slotClass } from './slots.js';
|
|
12
13
|
/**
|
|
13
14
|
* The last step of the booking wizard: contact details, who each seat is for,
|
|
@@ -154,10 +155,10 @@ function knownWho(state, index, labels) {
|
|
|
154
155
|
const item = state.items[index];
|
|
155
156
|
if (item?.adult) {
|
|
156
157
|
return state.people[index]?.key === 'self'
|
|
157
|
-
? labels['details.known.self']
|
|
158
|
-
: labels['details.known.adult'];
|
|
158
|
+
? labelText(labels['details.known.self'])
|
|
159
|
+
: labelText(labels['details.known.adult']);
|
|
159
160
|
}
|
|
160
|
-
return item?.bookedForName ?? labels['details.known.child'];
|
|
161
|
+
return item?.bookedForName ?? labelText(labels['details.known.child']);
|
|
161
162
|
}
|
|
162
163
|
/**
|
|
163
164
|
* Whether step 1 already said who sits in this seat: a saved person's card
|
|
@@ -200,7 +201,8 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
|
|
|
200
201
|
const showEmailError = !emailValid && (attempted || focused !== 'email');
|
|
201
202
|
const showTermsError = attempted && !state.consentTerms;
|
|
202
203
|
const price = format.price(totalOre);
|
|
203
|
-
|
|
204
|
+
// `null` when the pack leaves it blank, in either form: no box, no description.
|
|
205
|
+
const phonePrefix = renderLabel(labels['details.phone.prefix']);
|
|
204
206
|
const FamilyChip = components?.FamilyChip ?? DefaultFamilyChip;
|
|
205
207
|
function setContact(name, value) {
|
|
206
208
|
onChange({ type: 'setContact', field: name, value });
|
|
@@ -242,6 +244,9 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
|
|
|
242
244
|
const resourceId = line?.resourceId ?? null;
|
|
243
245
|
return {
|
|
244
246
|
serviceId: item.service.id,
|
|
247
|
+
...(item.extraServices && item.extraServices.length > 0
|
|
248
|
+
? { extraServiceIds: item.extraServices.map((service) => service.id) }
|
|
249
|
+
: {}),
|
|
245
250
|
...(resourceId === null ? {} : { resourceId }),
|
|
246
251
|
startTs: line?.startTs ?? startTs,
|
|
247
252
|
...present('bookedForName', isKnownChair(state, index) ? (item.bookedForName ?? '') : child.name),
|
|
@@ -286,11 +291,11 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
|
|
|
286
291
|
}
|
|
287
292
|
const inputClass = (...extra) => slotClass(classNames, 'input', BOOKING_INPUT_CLASS, ...extra);
|
|
288
293
|
const fieldClass = slotClass(classNames, 'field');
|
|
289
|
-
return (_jsxs("section", { "aria-labelledby": "booking-details-heading", className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h2", { id: "booking-details-heading", tabIndex: -1, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: labels['details.heading'] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('phone'), children: labels['details.phone.label'] }), _jsxs("div", { className: "flex items-stretch", children: [phonePrefix && (_jsx("span", { id: field('phone-prefix'), className: "flex h-12 items-center rounded-l-[4px] border border-r-0 border-input bg-muted px-4 text-base text-muted-foreground", children: phonePrefix })), _jsx(Input, { id: field('phone'), ref: phoneRef, type: "tel", inputMode: "numeric", autoComplete: "tel-national", className: inputClass(phonePrefix ? 'rounded-l-none' : ''), value: state.contact.phone, "aria-describedby": phonePrefix ? field('phone-prefix') : undefined, "aria-invalid": showPhoneError || undefined, onFocus: () => setFocused('phone'), onBlur: () => setFocused(null), onChange: (event) => setContact('phone', event.target.value) })] }), showPhoneError && _jsx(Field.Error, { children: labels['details.phone.error'] })] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('name'), children: labels['details.name.label'] }), _jsx(Input, { id: field('name'), autoComplete: "name", className: inputClass(), value: state.contact.name, onChange: (event) => setContact('name', event.target.value) })] }), state.items.some((_, index) => isKnownChair(state, index)) && (_jsx("ul", { "aria-label": labels['details.known.label'], className: "space-y-1 text-sm", children: state.items.map((item, index) => isKnownChair(state, index) ? (_jsxs("li", { children: [_jsx("span", { className: "font-semibold", children: item
|
|
294
|
+
return (_jsxs("section", { "aria-labelledby": "booking-details-heading", className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h2", { id: "booking-details-heading", tabIndex: -1, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: labels['details.heading'] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('phone'), children: labels['details.phone.label'] }), _jsxs("div", { className: "flex items-stretch", children: [phonePrefix && (_jsx("span", { id: field('phone-prefix'), className: "flex h-12 items-center rounded-l-[4px] border border-r-0 border-input bg-muted px-4 text-base text-muted-foreground", children: phonePrefix })), _jsx(Input, { id: field('phone'), ref: phoneRef, type: "tel", inputMode: "numeric", autoComplete: "tel-national", className: inputClass(phonePrefix ? 'rounded-l-none' : ''), value: state.contact.phone, "aria-describedby": phonePrefix ? field('phone-prefix') : undefined, "aria-invalid": showPhoneError || undefined, onFocus: () => setFocused('phone'), onBlur: () => setFocused(null), onChange: (event) => setContact('phone', event.target.value) })] }), showPhoneError && _jsx(Field.Error, { children: labels['details.phone.error'] })] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('name'), children: labels['details.name.label'] }), _jsx(Input, { id: field('name'), autoComplete: "name", className: inputClass(), value: state.contact.name, onChange: (event) => setContact('name', event.target.value) })] }), state.items.some((_, index) => isKnownChair(state, index)) && (_jsx("ul", { "aria-label": labelText(labels['details.known.label']), className: "space-y-1 text-sm", children: state.items.map((item, index) => isKnownChair(state, index) ? (_jsxs("li", { children: [_jsx("span", { className: "font-semibold", children: visitName(item) }), ' ', renderLabel(labels['details.known.for'], { who: knownWho(state, index, labels) })] }, index)) : null) })), state.items.map((item, index) =>
|
|
290
295
|
// A seat step 1 already named is not asked again: a second answer could disagree.
|
|
291
296
|
isKnownChair(state, index) ? null : (_jsx(ChildFields
|
|
292
297
|
// biome-ignore lint/suspicious/noArrayIndexKey: two lines can share a service; the index is the identity
|
|
293
|
-
, { legend: state.items.length > 1 ? item
|
|
298
|
+
, { legend: state.items.length > 1 ? visitName(item) : null, nameId: field(`child-name-${index}`), yearId: field(`child-year-${index}`), value: childAt(index), family: family, labels: labels, classNames: classNames, FamilyChip: FamilyChip, onChange: (patch, personId) => setChild(index, patch, personId) }, index))), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('email'), children: labels['details.email.label'] }), _jsx(Input, { id: field('email'), ref: emailRef, type: "email", autoComplete: "email", className: inputClass(), value: state.contact.email, "aria-describedby": field('email-help'), "aria-invalid": showEmailError || undefined, onFocus: () => setFocused('email'), onBlur: () => setFocused(null), onChange: (event) => setContact('email', event.target.value) }), _jsx(Field.Description, { id: field('email-help'), children: labels['details.email.help'] }), showEmailError && _jsx(Field.Error, { children: labels['details.email.error'] })] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('notes'), children: labels['details.notes.label'] }), _jsx(Textarea, { id: field('notes'), rows: 3, className: slotClass(classNames, 'input', BOOKING_TEXTAREA_CLASS), placeholder: labelText(labels['details.notes.placeholder']), value: state.notes, onChange: (event) => onChange({ type: 'setNotes', value: event.target.value }) })] }), _jsxs(Field, { orientation: "horizontal", className: slotClass(classNames, 'consent'), children: [_jsx(Checkbox, { id: field('terms'), ref: termsRef, className: BOOKING_CHECKBOX_CLASS, checked: state.consentTerms, "aria-invalid": showTermsError || undefined, onCheckedChange: (accepted) => onChange({ type: 'setConsent', which: 'terms', accepted }) }), _jsx(Field.Label, { htmlFor: field('terms'), className: "cursor-pointer font-normal", children: labels['details.terms.text'] }), termsHref && (_jsx("a", { href: termsHref, className: "col-start-2 text-sm font-semibold text-primary underline underline-offset-4", children: labels['details.terms.link'] }))] }), marketingConsent && (_jsxs(Field, { orientation: "horizontal", className: slotClass(classNames, 'consent'), children: [_jsx(Checkbox, { id: field('marketing'), className: BOOKING_CHECKBOX_CLASS, checked: state.consentMarketing, onCheckedChange: (accepted) => onChange({ type: 'setConsent', which: 'marketing', accepted }) }), _jsx(Field.Label, { htmlFor: field('marketing'), className: "cursor-pointer font-normal", children: labels['details.marketing.text'] })] })), state.error && (_jsx(SubmitFailure, { error: state.error, phone: phone, format: format, labels: labels, className: slotClass(classNames, 'error', 'rounded-lg border border-destructive/40 bg-destructive/10 px-5 py-3 text-sm') })), _jsx(BookingButton, { size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: submitting, "aria-busy": submitting || undefined, onClick: handleSubmit, children: renderLabel(labels['details.submit'], { price }) })] }));
|
|
294
299
|
}
|
|
295
300
|
/**
|
|
296
301
|
* The name and birth year for one line. Wrapped in a fieldset only when there
|
|
@@ -298,7 +303,7 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
|
|
|
298
303
|
* them is how one child's name lands on the other's booking.
|
|
299
304
|
*/
|
|
300
305
|
function ChildFields({ legend, nameId, yearId, value, family, labels, classNames, FamilyChip, onChange, }) {
|
|
301
|
-
const fields = (_jsxs("div", { className: "grid grid-cols-[1fr_7rem] gap-3", children: [_jsxs(Field, { className: slotClass(classNames, 'field'), children: [_jsx(Field.Label, { htmlFor: nameId, children: labels['details.child.name'] }), _jsx(Input, { id: nameId, className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS), value: value.name, onChange: (event) => onChange({ name: event.target.value }) })] }), _jsxs(Field, { className: slotClass(classNames, 'field'), children: [_jsx(Field.Label, { htmlFor: yearId, children: labels['details.child.year'] }), _jsx(Input, { id: yearId, inputMode: "numeric", maxLength: 4, className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS), placeholder: labels['details.child.yearPlaceholder'], value: value.birthYear, onChange: (event) => onChange({ birthYear: event.target.value }) })] })] }));
|
|
306
|
+
const fields = (_jsxs("div", { className: "grid grid-cols-[1fr_7rem] gap-3", children: [_jsxs(Field, { className: slotClass(classNames, 'field'), children: [_jsx(Field.Label, { htmlFor: nameId, children: labels['details.child.name'] }), _jsx(Input, { id: nameId, className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS), value: value.name, onChange: (event) => onChange({ name: event.target.value }) })] }), _jsxs(Field, { className: slotClass(classNames, 'field'), children: [_jsx(Field.Label, { htmlFor: yearId, children: labels['details.child.year'] }), _jsx(Input, { id: yearId, inputMode: "numeric", maxLength: 4, className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS), placeholder: labelText(labels['details.child.yearPlaceholder']), value: value.birthYear, onChange: (event) => onChange({ birthYear: event.target.value }) })] })] }));
|
|
302
307
|
const withSuggestions = (_jsxs("div", { className: "space-y-3", children: [family !== undefined && family.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: family.map((member) => (_jsx(FamilyChip, { member: member, pressed: value.name === member.name && value.birthYear === String(member.birthYear), onPick: () => onChange({ name: member.name, birthYear: String(member.birthYear) }, member.personId) }, `${member.name}-${member.birthYear}`))) })), fields] }));
|
|
303
308
|
if (legend === null)
|
|
304
309
|
return withSuggestions;
|
package/dist/booking/index.d.ts
CHANGED
|
@@ -3,10 +3,10 @@ export * from './booking-skeleton.js';
|
|
|
3
3
|
export * from './confirmation.js';
|
|
4
4
|
export * from './details-screen.js';
|
|
5
5
|
export type { BookingClock, BookingDaypart, BookingFormat } from './format.js';
|
|
6
|
-
export { joinLabelParts, labelParts, renderLabel } from './internal/label-parts.js';
|
|
6
|
+
export { type JoinLabelPartsOptions, joinLabelParts, labelParts, renderLabel, } from './internal/label-parts.js';
|
|
7
7
|
export * from './internal/ui.js';
|
|
8
8
|
export * from './label-keys.js';
|
|
9
|
-
export { fillLabel } from './labels.js';
|
|
9
|
+
export { type BookingLabel, fillLabel, fillLabelPieces, labelText } from './labels.js';
|
|
10
10
|
export * from './live-status.js';
|
|
11
11
|
export * from './login-panel.js';
|
|
12
12
|
export * from './login-sheet.js';
|
package/dist/booking/index.js
CHANGED
|
@@ -10,10 +10,10 @@ export * from './add-child-sheet.js';
|
|
|
10
10
|
export * from './booking-skeleton.js';
|
|
11
11
|
export * from './confirmation.js';
|
|
12
12
|
export * from './details-screen.js';
|
|
13
|
-
export { joinLabelParts, labelParts, renderLabel } from './internal/label-parts.js';
|
|
13
|
+
export { joinLabelParts, labelParts, renderLabel, } from './internal/label-parts.js';
|
|
14
14
|
export * from './internal/ui.js';
|
|
15
15
|
export * from './label-keys.js';
|
|
16
|
-
export { fillLabel } from './labels.js';
|
|
16
|
+
export { fillLabel, fillLabelPieces, labelText } from './labels.js';
|
|
17
17
|
export * from './live-status.js';
|
|
18
18
|
export * from './login-panel.js';
|
|
19
19
|
export * from './login-sheet.js';
|
|
@@ -1,28 +1,43 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
|
+
import { type BookingLabel } from '../labels.js';
|
|
2
3
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
4
|
+
* A filled label as element children (see `BookingLabel`): a string renders
|
|
5
|
+
* as one text node, an array as one text node per element. Prefer it over
|
|
6
|
+
* `fillLabel` wherever a label is rendered as children, so the pack decides
|
|
7
|
+
* where the sentence breaks.
|
|
8
|
+
*
|
|
9
|
+
* Returns the string itself for a one-node label, an array of strings for a
|
|
10
|
+
* split one, and `null` for a label that fills to nothing at all. Text-only
|
|
11
|
+
* targets (`aria-label`, `title`, a file name) keep `fillLabel`.
|
|
7
12
|
*/
|
|
8
|
-
export declare function
|
|
13
|
+
export declare function renderLabel(template: BookingLabel, values?: Readonly<Record<string, string | number>>): string | string[] | null;
|
|
9
14
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* sentence composed in JSX.
|
|
17
|
-
*
|
|
18
|
-
* Text-only targets (`aria-label`, `title`, a file name) keep `fillLabel`.
|
|
19
|
-
* Empty literal runs and empty values render nothing, as in JSX; a label
|
|
20
|
-
* that fills to nothing at all is `null`.
|
|
15
|
+
* `renderLabel` for templates whose placeholders include elements (a link, a
|
|
16
|
+
* button) rather than strings: `'Have an account? {trigger} – …'` with
|
|
17
|
+
* `{ trigger: <button>…</button> }`. Each element sits in its own place; the
|
|
18
|
+
* text around it follows the label's form — a string's text runs stay whole
|
|
19
|
+
* between elements, an array's elements stay separate. String and number
|
|
20
|
+
* values join the text they sit in. Unknown placeholders are left as written.
|
|
21
21
|
*/
|
|
22
|
-
export declare function
|
|
22
|
+
export declare function labelParts(template: BookingLabel, parts: Readonly<Record<string, ReactNode>>): ReactNode;
|
|
23
|
+
export interface JoinLabelPartsOptions {
|
|
24
|
+
/**
|
|
25
|
+
* `false`: the line is ONE text node (every part is flattened to text and
|
|
26
|
+
* joined). `true`: every part and every separator is its own text node, and
|
|
27
|
+
* a split part (an array, from a `string[]` label) keeps its pieces.
|
|
28
|
+
*
|
|
29
|
+
* Default: one text node when every part is plain text, pieces as soon as
|
|
30
|
+
* one part is split (a pack wrote one of the line's labels as an array) or
|
|
31
|
+
* is an element. A part that cannot be flattened (an element) always
|
|
32
|
+
* renders in pieces.
|
|
33
|
+
*/
|
|
34
|
+
pieces?: boolean;
|
|
35
|
+
}
|
|
23
36
|
/**
|
|
24
|
-
* `parts.filter(Boolean).join(separator)` as element children
|
|
25
|
-
*
|
|
26
|
-
*
|
|
37
|
+
* `parts.filter(Boolean).join(separator)` as element children. Blank parts
|
|
38
|
+
* drop out, so an absent one closes the gap rather than leaving a doubled
|
|
39
|
+
* separator. Whether the line is one text node or one per part and separator
|
|
40
|
+
* follows the parts' own form (see `JoinLabelPartsOptions.pieces`): plain
|
|
41
|
+
* strings join into one, as `[a, b].join(' · ')` in JSX did.
|
|
27
42
|
*/
|
|
28
|
-
export declare function joinLabelParts(parts: ReadonlyArray<ReactNode>, separator: string): ReactNode;
|
|
43
|
+
export declare function joinLabelParts(parts: ReadonlyArray<ReactNode>, separator: string, options?: JoinLabelPartsOptions): ReactNode;
|
|
@@ -1,57 +1,85 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Fragment } from 'react';
|
|
3
|
+
import { fillLabelPieces } from '../labels.js';
|
|
3
4
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
5
|
+
* A filled label as element children (see `BookingLabel`): a string renders
|
|
6
|
+
* as one text node, an array as one text node per element. Prefer it over
|
|
7
|
+
* `fillLabel` wherever a label is rendered as children, so the pack decides
|
|
8
|
+
* where the sentence breaks.
|
|
9
|
+
*
|
|
10
|
+
* Returns the string itself for a one-node label, an array of strings for a
|
|
11
|
+
* split one, and `null` for a label that fills to nothing at all. Text-only
|
|
12
|
+
* targets (`aria-label`, `title`, a file name) keep `fillLabel`.
|
|
8
13
|
*/
|
|
9
|
-
export function
|
|
10
|
-
const pieces = template
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: the pieces of a fixed template never reorder
|
|
15
|
-
return _jsx(Fragment, { children: parts[match[1]] }, index);
|
|
16
|
-
}
|
|
17
|
-
return piece;
|
|
18
|
-
});
|
|
14
|
+
export function renderLabel(template, values = {}) {
|
|
15
|
+
const pieces = fillLabelPieces(template, values);
|
|
16
|
+
if (pieces.length === 0)
|
|
17
|
+
return null;
|
|
18
|
+
return typeof template === 'string' ? pieces[0] : pieces;
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* sentence composed in JSX.
|
|
28
|
-
*
|
|
29
|
-
* Text-only targets (`aria-label`, `title`, a file name) keep `fillLabel`.
|
|
30
|
-
* Empty literal runs and empty values render nothing, as in JSX; a label
|
|
31
|
-
* that fills to nothing at all is `null`.
|
|
21
|
+
* `renderLabel` for templates whose placeholders include elements (a link, a
|
|
22
|
+
* button) rather than strings: `'Have an account? {trigger} – …'` with
|
|
23
|
+
* `{ trigger: <button>…</button> }`. Each element sits in its own place; the
|
|
24
|
+
* text around it follows the label's form — a string's text runs stay whole
|
|
25
|
+
* between elements, an array's elements stay separate. String and number
|
|
26
|
+
* values join the text they sit in. Unknown placeholders are left as written.
|
|
32
27
|
*/
|
|
33
|
-
export function
|
|
34
|
-
const pieces = template
|
|
28
|
+
export function labelParts(template, parts) {
|
|
29
|
+
const pieces = typeof template === 'string' ? [template] : template;
|
|
35
30
|
const nodes = [];
|
|
36
31
|
for (const piece of pieces) {
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
let run = '';
|
|
33
|
+
for (const token of piece.split(/(\{\w+\})/g)) {
|
|
34
|
+
const name = /^\{(\w+)\}$/.exec(token)?.[1];
|
|
35
|
+
const value = name !== undefined && Object.hasOwn(parts, name) ? parts[name] : token;
|
|
36
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
37
|
+
run += String(value);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (run !== '')
|
|
41
|
+
nodes.push(run);
|
|
42
|
+
run = '';
|
|
43
|
+
nodes.push(_jsx(Fragment, { children: value }, nodes.length));
|
|
44
|
+
}
|
|
45
|
+
if (run !== '')
|
|
46
|
+
nodes.push(run);
|
|
47
|
+
}
|
|
48
|
+
return nodes;
|
|
49
|
+
}
|
|
50
|
+
/** A part as plain text, or `null` when it holds an element. */
|
|
51
|
+
function partText(part) {
|
|
52
|
+
if (typeof part === 'string' || typeof part === 'number')
|
|
53
|
+
return String(part);
|
|
54
|
+
if (Array.isArray(part)) {
|
|
55
|
+
let text = '';
|
|
56
|
+
for (const piece of part) {
|
|
57
|
+
const pieceText = partText(piece);
|
|
58
|
+
if (pieceText === null)
|
|
59
|
+
return null;
|
|
60
|
+
text += pieceText;
|
|
61
|
+
}
|
|
62
|
+
return text;
|
|
41
63
|
}
|
|
42
|
-
return
|
|
64
|
+
return null;
|
|
43
65
|
}
|
|
44
66
|
/**
|
|
45
|
-
* `parts.filter(Boolean).join(separator)` as element children
|
|
46
|
-
*
|
|
47
|
-
*
|
|
67
|
+
* `parts.filter(Boolean).join(separator)` as element children. Blank parts
|
|
68
|
+
* drop out, so an absent one closes the gap rather than leaving a doubled
|
|
69
|
+
* separator. Whether the line is one text node or one per part and separator
|
|
70
|
+
* follows the parts' own form (see `JoinLabelPartsOptions.pieces`): plain
|
|
71
|
+
* strings join into one, as `[a, b].join(' · ')` in JSX did.
|
|
48
72
|
*/
|
|
49
|
-
export function joinLabelParts(parts, separator) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
73
|
+
export function joinLabelParts(parts, separator, options = {}) {
|
|
74
|
+
// Blank = renders no text: nothing at all, or text pieces that are all empty.
|
|
75
|
+
const present = parts.filter((part) => part !== null && part !== undefined && typeof part !== 'boolean' && partText(part) !== '');
|
|
76
|
+
const pieces = options.pieces ?? present.some((part) => typeof part !== 'string' && typeof part !== 'number');
|
|
77
|
+
if (!pieces) {
|
|
78
|
+
const texts = present.map(partText);
|
|
79
|
+
if (texts.every((text) => text !== null)) {
|
|
80
|
+
const line = texts.join(separator);
|
|
81
|
+
return line === '' ? null : line;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return present.map((part, index) => (_jsxs(Fragment, { children: [index > 0 && separator, part] }, index)));
|
|
57
85
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { BookingFormat } from '../format.js';
|
|
3
|
+
import type { ServiceMultiLabels, ServicePartyPerson, ServiceScreenSlot, ServiceSelection } from '../service-screen.js';
|
|
4
|
+
import { type SlotClassNames } from '../slots.js';
|
|
5
|
+
/**
|
|
6
|
+
* The service step's multi-select parts (`ServiceScreenProps.selection`):
|
|
7
|
+
* the per-person tabs, the sticky total bar and the checkbox card. Internal —
|
|
8
|
+
* `ServiceScreen` composes them; the package does not export them.
|
|
9
|
+
*/
|
|
10
|
+
export declare const serviceTabId: (index: number) => string;
|
|
11
|
+
export declare const serviceTabPanelId: (index: number) => string;
|
|
12
|
+
/**
|
|
13
|
+
* One tab per person on the multi-select step, a tick on everyone who has
|
|
14
|
+
* something. The WAI-ARIA tabs pattern: one tab stop, arrows move between
|
|
15
|
+
* people, Home / End jump to the ends; moving selects (one short panel, no
|
|
16
|
+
* cost to showing it).
|
|
17
|
+
*/
|
|
18
|
+
export declare function PersonTabs({ people, active, done, onSelect, labels, labelledBy, describedBy, classNames, }: {
|
|
19
|
+
people: ReadonlyArray<ServicePartyPerson>;
|
|
20
|
+
active: number;
|
|
21
|
+
done: (index: number) => boolean;
|
|
22
|
+
onSelect: (index: number) => void;
|
|
23
|
+
labels: ServiceMultiLabels;
|
|
24
|
+
labelledBy: string;
|
|
25
|
+
describedBy: string;
|
|
26
|
+
classNames: SlotClassNames<ServiceScreenSlot> | undefined;
|
|
27
|
+
}): import("react").JSX.Element;
|
|
28
|
+
/**
|
|
29
|
+
* The multi-select step's sticky bar: the running total, what is still
|
|
30
|
+
* missing, and «Next». The same bar as `SummaryBar`, so the step reads as one
|
|
31
|
+
* flow; a caller that also renders `SummaryBar` hides it on this step.
|
|
32
|
+
*
|
|
33
|
+
* «Next» is never disabled. A disabled button explains nothing and is skipped
|
|
34
|
+
* by a screen reader's tab order; this one stays, the line says what is
|
|
35
|
+
* missing, and the caller decides what pressing it does.
|
|
36
|
+
*/
|
|
37
|
+
export declare function TotalBar({ selection, format, classNames, }: {
|
|
38
|
+
selection: ServiceSelection;
|
|
39
|
+
format: BookingFormat;
|
|
40
|
+
classNames: SlotClassNames<ServiceScreenSlot> | undefined;
|
|
41
|
+
}): import("react").JSX.Element;
|
|
42
|
+
/**
|
|
43
|
+
* A service card on the multi-select step: a real checkbox inside its label.
|
|
44
|
+
* Space (and Enter) toggles it, the label's whole box is the target, and a
|
|
45
|
+
* screen reader hears «checkbox, checked».
|
|
46
|
+
*/
|
|
47
|
+
export declare function MultiServiceCard({ name, selected, onPick, duration, price, className, selectedClassName, }: {
|
|
48
|
+
name: string;
|
|
49
|
+
selected: boolean;
|
|
50
|
+
onPick: () => void;
|
|
51
|
+
duration: ReactNode;
|
|
52
|
+
price: ReactNode;
|
|
53
|
+
className?: string;
|
|
54
|
+
selectedClassName?: string;
|
|
55
|
+
}): import("react").JSX.Element;
|