@medalsocial/meda 3.6.0 → 3.7.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.
@@ -0,0 +1,60 @@
1
+ import type { BookingFormat } from './format.js';
2
+ import { type BookingLabel } from './labels.js';
3
+ import { type SlotClassNames } from './slots.js';
4
+ /**
5
+ * What is about to be booked, at the top of the last step.
6
+ *
7
+ * Without it the details step asks for a phone number and a «confirm» under
8
+ * no day, no time and no stylist: the visitor agrees to a price alone. This
9
+ * card names the day (a calendar leaf), the hours, each person's services and
10
+ * who takes them, and the total — and offers the two changes worth one tap:
11
+ * «edit» back to the time step, and, when «first available» resolved to one
12
+ * stylist while others are free at the same minute, a swap to one of them.
13
+ *
14
+ * Presentational: the caller resolves who and when (the same lines the
15
+ * submission is built from), so the card cannot describe a different booking.
16
+ */
17
+ export declare const BOOKING_RECAP_LABEL_KEYS: readonly ["recap.label", "recap.time", "recap.line", "recap.lineFor", "recap.anyStylist", "recap.total", "recap.edit", "recap.editLabel", "recap.alsoFree", "recap.swapTo"];
18
+ export type BookingRecapLabels = Record<(typeof BOOKING_RECAP_LABEL_KEYS)[number], BookingLabel>;
19
+ /** One person's part of the visit. */
20
+ export interface BookingRecapLine {
21
+ startTs: number;
22
+ endTs: number;
23
+ /** The services, in the order they are done. */
24
+ services: readonly string[];
25
+ /** Who takes this line; `null` until a slot names somebody. Cleaned with `format.stylistName`. */
26
+ stylist: {
27
+ name: string;
28
+ photoUrl?: string | null;
29
+ } | null;
30
+ /** Whose line it is; only drawn when there is more than one line. */
31
+ who?: string | null;
32
+ }
33
+ /** A stylist free at the same start who could take the whole visit instead. */
34
+ export interface BookingRecapAlternative {
35
+ resourceId: string;
36
+ name: string;
37
+ photoUrl?: string | null;
38
+ }
39
+ /**
40
+ * - `root` the card
41
+ * - `leaf` the calendar leaf
42
+ * - `edit` the edit button
43
+ * - `alternatives` the swap row
44
+ */
45
+ export type BookingRecapSlot = 'root' | 'leaf' | 'edit' | 'alternatives';
46
+ export interface BookingRecapProps {
47
+ /** At least one, in the order the visit runs. */
48
+ lines: readonly BookingRecapLine[];
49
+ /** The whole visit in minor units. */
50
+ totalOre: number;
51
+ format: BookingFormat;
52
+ labels: BookingRecapLabels;
53
+ /** Back to the time step. Absent: no edit button. */
54
+ onEdit?: () => void;
55
+ /** Others free at the same start. Drawn with `onSwap`; at most three are shown. */
56
+ alternatives?: readonly BookingRecapAlternative[];
57
+ onSwap?: (resourceId: string) => void;
58
+ classNames?: SlotClassNames<BookingRecapSlot>;
59
+ }
60
+ export declare function BookingRecap({ lines, totalOre, format, labels, onEdit, alternatives, onSwap, classNames, }: BookingRecapProps): import("react").JSX.Element | null;
@@ -0,0 +1,70 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { cn } from '../lib/utils.js';
4
+ import { labelParts, renderLabel } from './internal/label-parts.js';
5
+ import { BookingButton } from './internal/ui.js';
6
+ import { fillLabel } from './labels.js';
7
+ import { slotClass } from './slots.js';
8
+ /**
9
+ * What is about to be booked, at the top of the last step.
10
+ *
11
+ * Without it the details step asks for a phone number and a «confirm» under
12
+ * no day, no time and no stylist: the visitor agrees to a price alone. This
13
+ * card names the day (a calendar leaf), the hours, each person's services and
14
+ * who takes them, and the total — and offers the two changes worth one tap:
15
+ * «edit» back to the time step, and, when «first available» resolved to one
16
+ * stylist while others are free at the same minute, a swap to one of them.
17
+ *
18
+ * Presentational: the caller resolves who and when (the same lines the
19
+ * submission is built from), so the card cannot describe a different booking.
20
+ */
21
+ export const BOOKING_RECAP_LABEL_KEYS = [
22
+ /** Accessible name of the card, e.g. «The appointment you are booking». */
23
+ 'recap.label',
24
+ /** `{start}` `{end}` — the visit's hours, e.g. «{start}–{end}». */
25
+ 'recap.time',
26
+ /** `{services}` `{stylist}` — one person's line, e.g. «{services} with {stylist}». */
27
+ 'recap.line',
28
+ /** `{who}` `{services}` `{stylist}` — a line in a family booking. */
29
+ 'recap.lineFor',
30
+ /** The stylist part when nobody is named yet, e.g. «the first free stylist». */
31
+ 'recap.anyStylist',
32
+ /** `{price}` — the total line, e.g. «{price}, paid at the salon». */
33
+ 'recap.total',
34
+ /** Visible text of the edit button. */
35
+ 'recap.edit',
36
+ /** Accessible name of the edit button, e.g. «Change day and time». */
37
+ 'recap.editLabel',
38
+ /** `{time}` — leads the swap row, e.g. «Also free at {time}:». */
39
+ 'recap.alsoFree',
40
+ /** `{name}` — accessible name of a swap button, e.g. «Book with {name} instead». */
41
+ 'recap.swapTo',
42
+ ];
43
+ const MAX_ALTERNATIVES = 3;
44
+ /** A stylist's face beside the name. Nothing without a photo: the name already says who. */
45
+ function Avatar({ photoUrl }) {
46
+ if (!photoUrl)
47
+ return null;
48
+ return (_jsx("img", { src: photoUrl, alt: "", width: 24, height: 24, decoding: "async", className: "size-6 shrink-0 rounded-full object-cover" }));
49
+ }
50
+ export function BookingRecap({ lines, totalOre, format, labels, onEdit, alternatives, onSwap, classNames, }) {
51
+ const first = lines[0];
52
+ if (!first)
53
+ return null;
54
+ const { clock } = format;
55
+ const last = lines.reduce((latest, line) => (line.endTs > latest.endTs ? line : latest), first);
56
+ const [, month, day] = clock.dayKey(first.startTs).split('-').map(Number);
57
+ const many = lines.length > 1;
58
+ const swaps = onSwap ? (alternatives ?? []).slice(0, MAX_ALTERNATIVES) : [];
59
+ const stylistOf = (line) => line.stylist ? (_jsxs("span", { className: "inline-flex items-center gap-1.5 whitespace-nowrap font-medium text-foreground", children: [_jsx(Avatar, { photoUrl: line.stylist.photoUrl }), format.stylistName(line.stylist.name)] })) : (renderLabel(labels['recap.anyStylist']));
60
+ return (_jsxs("section", { "aria-label": fillLabel(labels['recap.label'], {}), "data-testid": "booking-recap", className: slotClass(classNames, 'root', 'rounded-lg border border-border bg-card'), children: [_jsxs("div", { className: "flex items-start gap-4 p-4", children: [_jsxs("span", { "aria-hidden": "true", className: slotClass(classNames, 'leaf', 'flex w-16 shrink-0 flex-col overflow-hidden rounded-md border border-border bg-background text-center'), children: [_jsx("span", { className: "truncate bg-primary px-1 py-0.5 text-[10px] font-bold uppercase tracking-wider text-primary-foreground", children: clock.weekday(first.startTs) }), _jsx("span", { className: "pt-1 font-sans text-2xl font-bold leading-none tabular-nums", children: day }), _jsx("span", { className: "truncate px-1 pt-0.5 pb-1.5 text-[11px] text-muted-foreground", children: month ? clock.monthName(month) : '' })] }), _jsxs("div", { className: "min-w-0 flex-1 space-y-1", children: [_jsxs("p", { className: "font-semibold tabular-nums", children: [_jsxs("span", { className: "sr-only", children: [clock.dayLabel(first.startTs), " "] }), renderLabel(labels['recap.time'], {
61
+ start: clock.formatTime(first.startTs),
62
+ end: clock.formatTime(last.endTs),
63
+ })] }), _jsx("ul", { className: "space-y-1 text-sm text-muted-foreground", children: lines.map((line, index) => (_jsx("li", { className: "flex flex-wrap items-center gap-x-1", children: labelParts(many && line.who ? labels['recap.lineFor'] : labels['recap.line'], {
64
+ who: line.who ?? '',
65
+ services: line.services.join(' + '),
66
+ stylist: stylistOf(line),
67
+ }) }, index))) }), _jsx("p", { className: "text-sm text-muted-foreground tabular-nums", children: renderLabel(labels['recap.total'], { price: format.price(totalOre) }) })] }), onEdit && (_jsx(BookingButton, { variant: "link", size: "sm", "aria-label": fillLabel(labels['recap.editLabel'], {}), onClick: onEdit, className: slotClass(classNames, 'edit', 'h-auto px-0 py-0.5'), children: renderLabel(labels['recap.edit']) }))] }), swaps.length > 0 && (_jsxs("div", { className: slotClass(classNames, 'alternatives', 'flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-border px-4 py-2.5 text-sm text-muted-foreground'), children: [_jsx("span", { children: renderLabel(labels['recap.alsoFree'], { time: clock.formatTime(first.startTs) }) }), swaps.map((alternative) => (_jsxs("button", { type: "button", "aria-label": fillLabel(labels['recap.swapTo'], {
68
+ name: format.stylistName(alternative.name),
69
+ }), onClick: () => onSwap?.(alternative.resourceId), className: cn('inline-flex min-h-11 items-center gap-1.5 rounded-full px-2 font-semibold text-primary', 'transition-colors hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'), children: [_jsx(Avatar, { photoUrl: alternative.photoUrl }), format.stylistName(alternative.name)] }, alternative.resourceId)))] }))] }));
70
+ }
@@ -61,7 +61,10 @@ export const DETAILS_SCREEN_LABEL_KEYS = [
61
61
  'details.call.link',
62
62
  /** The rescue sentence when there is no phone number to link. */
63
63
  'details.call.none',
64
- /** `{price}` — the total, already formatted. */
64
+ /**
65
+ * `{price}` — the total, already formatted; `{day}` (`clock.dayLabel`) and
66
+ * `{time}` — when the visit starts, so the button can name what it books.
67
+ */
65
68
  'details.submit',
66
69
  ];
67
70
  /**
@@ -299,7 +302,11 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
299
302
  // A seat step 1 already named is not asked again: a second answer could disagree.
300
303
  isKnownChair(state, index) ? null : (_jsx(ChildFields
301
304
  // biome-ignore lint/suspicious/noArrayIndexKey: two lines can share a service; the index is the identity
302
- , { 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, readOnly: emailLocked || undefined, "aria-readonly": emailLocked || undefined, "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: emailLocked ? labels['details.email.lockedHelp'] : 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 }) })] }));
305
+ , { 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, readOnly: emailLocked || undefined, "aria-readonly": emailLocked || undefined, "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: emailLocked ? labels['details.email.lockedHelp'] : 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'], {
306
+ price,
307
+ day: state.startTs === null ? '' : format.clock.dayLabel(state.startTs),
308
+ time: state.startTs === null ? '' : format.clock.formatTime(state.startTs),
309
+ }) })] }));
303
310
  }
304
311
  /**
305
312
  * The name and birth year for one line. Wrapped in a fieldset only when there
@@ -1,4 +1,5 @@
1
1
  export * from './add-child-sheet.js';
2
+ export * from './booking-recap.js';
2
3
  export * from './booking-skeleton.js';
3
4
  export * from './confirmation.js';
4
5
  export * from './details-screen.js';
@@ -7,6 +7,7 @@
7
7
  // Styles: import `@medalsocial/meda/booking/styles.css` and
8
8
  // `@medalsocial/meda/primitives/styles.css` after the bridge.
9
9
  export * from './add-child-sheet.js';
10
+ export * from './booking-recap.js';
10
11
  export * from './booking-skeleton.js';
11
12
  export * from './confirmation.js';
12
13
  export * from './details-screen.js';
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Check } from 'lucide-react';
4
4
  import { cn } from '../../lib/utils.js';
5
+ import { labelText } from '../labels.js';
5
6
  import { LiveStatus } from '../live-status.js';
6
7
  import { slotClass } from '../slots.js';
7
8
  import { renderLabel } from './label-parts.js';
@@ -38,13 +39,13 @@ export function PersonTabs({ people, active, done, onSelect, labels, labelledBy,
38
39
  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
40
  const selected = index === active;
40
41
  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
+ return (_jsxs("button", { type: "button", role: "tab", id: serviceTabId(index), "aria-selected": selected, "aria-label": finished ? `${person.label}, ${labelText(labels['service.tabDone'])}` : undefined, "aria-controls": selected ? serviceTabPanelId(index) : undefined, tabIndex: selected ? 0 : -1, onClick: () => onSelect(index),
42
43
  // Weight and ring carry the open tab alongside the colour, as the
43
44
  // category pills do. Focus is an outline, so it stacks on the
44
45
  // selection ring instead of replacing it with a fainter one.
45
46
  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
47
  ? '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
+ : '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] }, person.key));
48
49
  }) }));
49
50
  }
50
51
  /**
@@ -1,4 +1,5 @@
1
1
  import { ADD_CHILD_SHEET_LABEL_KEYS } from './add-child-sheet.js';
2
+ import { BOOKING_RECAP_LABEL_KEYS } from './booking-recap.js';
2
3
  import { BOOKING_SKELETON_LABEL_KEYS } from './booking-skeleton.js';
3
4
  import { CONFIRMATION_LABEL_KEYS } from './confirmation.js';
4
5
  import { DETAILS_SCREEN_LABEL_KEYS } from './details-screen.js';
@@ -18,9 +19,9 @@ import { VIPPS_LINK_ROW_LABEL_KEYS } from './portal/vipps-link-row.js';
18
19
  import { VISIT_HISTORY_LABEL_KEYS } from './portal/visit-history.js';
19
20
  import { SERVICE_SCREEN_LABEL_KEYS, SERVICE_SCREEN_MULTI_LABEL_KEYS } from './service-screen.js';
20
21
  import { STYLIST_SCREEN_LABEL_KEYS } from './stylist-screen.js';
21
- import { SUMMARY_BAR_LABEL_KEYS } from './summary-bar.js';
22
- import { TIME_SCREEN_LABEL_KEYS } from './time-screen.js';
23
- import { WHO_SCREEN_LABEL_KEYS } from './who-screen.js';
22
+ import { SUMMARY_BAR_HINT_LABEL_KEYS, SUMMARY_BAR_LABEL_KEYS } from './summary-bar.js';
23
+ import { TIME_SCREEN_FINDER_LABEL_KEYS, TIME_SCREEN_LABEL_KEYS } from './time-screen.js';
24
+ import { WHO_SCREEN_LABEL_KEYS, WHO_SCREEN_PARTY_LABEL_KEYS } from './who-screen.js';
24
25
  /**
25
26
  * Every label key any booking screen reads, once. A complete pack for a
26
27
  * locale is a `BookingLabels`; each screen's `labels` prop is a subset of it,
@@ -29,13 +30,13 @@ import { WHO_SCREEN_LABEL_KEYS } from './who-screen.js';
29
30
  * (Screens that embed another screen already include its keys, e.g. the
30
31
  * login sheet carries the login panel's, OTP slots' and Vipps button's.)
31
32
  */
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.lockedHelp" | "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.continueEmail" | "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 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" | "recap.label" | "recap.time" | "recap.line" | "recap.lineFor" | "recap.anyStylist" | "recap.total" | "recap.edit" | "recap.editLabel" | "recap.alsoFree" | "recap.swapTo" | "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.lockedHelp" | "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.continueEmail" | "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" | "time.soonest.heading" | "time.soonest.pick" | "time.soonest.with" | "time.dayChip.free" | "time.dayChip.freeOne" | "time.dayChip.full" | "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" | "summary.hint.who" | "summary.hint.service" | "summary.hint.when" | "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" | "who.party.children" | "who.party.childrenNote" | "who.party.adult" | "who.party.adultNote" | "who.party.fewer" | "who.party.more" | "who.party.count")[];
33
34
  export type BookingLabelKey = BookingRequiredLabelKey | BookingOptionalLabelKey;
34
35
  /**
35
36
  * Keys added after a screen first shipped, for a feature a caller opts into.
36
37
  * A pack may leave them out; a screen using the feature needs them.
37
38
  */
38
- export type BookingOptionalLabelKey = (typeof SERVICE_SCREEN_MULTI_LABEL_KEYS)[number];
39
+ export type BookingOptionalLabelKey = (typeof SERVICE_SCREEN_MULTI_LABEL_KEYS)[number] | (typeof BOOKING_RECAP_LABEL_KEYS)[number] | (typeof TIME_SCREEN_FINDER_LABEL_KEYS)[number] | (typeof SUMMARY_BAR_HINT_LABEL_KEYS)[number] | (typeof WHO_SCREEN_PARTY_LABEL_KEYS)[number];
39
40
  /** Keys every complete pack defines. */
40
41
  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
42
  /** A complete label pack for one locale. The opt-in keys may be absent. */
@@ -1,4 +1,5 @@
1
1
  import { ADD_CHILD_SHEET_LABEL_KEYS } from './add-child-sheet.js';
2
+ import { BOOKING_RECAP_LABEL_KEYS } from './booking-recap.js';
2
3
  import { BOOKING_SKELETON_LABEL_KEYS } from './booking-skeleton.js';
3
4
  import { CONFIRMATION_LABEL_KEYS } from './confirmation.js';
4
5
  import { DETAILS_SCREEN_LABEL_KEYS } from './details-screen.js';
@@ -17,9 +18,9 @@ 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
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
- import { SUMMARY_BAR_LABEL_KEYS } from './summary-bar.js';
21
- import { TIME_SCREEN_LABEL_KEYS } from './time-screen.js';
22
- import { WHO_SCREEN_LABEL_KEYS } from './who-screen.js';
21
+ import { SUMMARY_BAR_HINT_LABEL_KEYS, SUMMARY_BAR_LABEL_KEYS } from './summary-bar.js';
22
+ import { TIME_SCREEN_FINDER_LABEL_KEYS, TIME_SCREEN_LABEL_KEYS } from './time-screen.js';
23
+ import { WHO_SCREEN_LABEL_KEYS, WHO_SCREEN_PARTY_LABEL_KEYS } from './who-screen.js';
23
24
  /**
24
25
  * Every label key any booking screen reads, once. A complete pack for a
25
26
  * locale is a `BookingLabels`; each screen's `labels` prop is a subset of it,
@@ -31,14 +32,18 @@ import { WHO_SCREEN_LABEL_KEYS } from './who-screen.js';
31
32
  export const BOOKING_LABEL_KEYS = [
32
33
  ...new Set([
33
34
  ...WHO_SCREEN_LABEL_KEYS,
35
+ ...WHO_SCREEN_PARTY_LABEL_KEYS,
34
36
  ...ADD_CHILD_SHEET_LABEL_KEYS,
35
37
  ...SERVICE_SCREEN_LABEL_KEYS,
36
38
  ...SERVICE_SCREEN_MULTI_LABEL_KEYS,
37
39
  ...STYLIST_SCREEN_LABEL_KEYS,
38
40
  ...TIME_SCREEN_LABEL_KEYS,
41
+ ...TIME_SCREEN_FINDER_LABEL_KEYS,
39
42
  ...MANAGE_SCREEN_LABEL_KEYS,
43
+ ...BOOKING_RECAP_LABEL_KEYS,
40
44
  ...DETAILS_SCREEN_LABEL_KEYS,
41
45
  ...SUMMARY_BAR_LABEL_KEYS,
46
+ ...SUMMARY_BAR_HINT_LABEL_KEYS,
42
47
  ...CONFIRMATION_LABEL_KEYS,
43
48
  ...BOOKING_SKELETON_LABEL_KEYS,
44
49
  ...LOGIN_SHEET_LABEL_KEYS,
@@ -51,6 +51,12 @@ export interface ServiceScreenCategory {
51
51
  key: string;
52
52
  /** The pill's and the section heading's text. */
53
53
  label: string;
54
+ /**
55
+ * Who the group is for. In a family, a child's menu is `childCategory` plus
56
+ * every group marked `'any'` (an ear piercing, say) — never a grown-ups'
57
+ * group. Absent: the group is a grown-ups' one, as before.
58
+ */
59
+ audience?: 'child' | 'adult' | 'any';
54
60
  }
55
61
  /** A person's «same as last time», and the one-line note when it was swapped (e.g. for age). */
56
62
  export interface ServiceSuggestion {
@@ -193,12 +193,19 @@ function defaultCategoryOf(categories) {
193
193
  * What a person in a party can have is what the business takes online and —
194
194
  * like the booking engine — nothing whose `maxPerBooking` is below the party's
195
195
  * size: the strictest service in a request sets the limit for all of it. With
196
- * a `childCategory`, a child gets that menu and an adult everything else,
197
- * which is often nothing, and is told so.
196
+ * a `childCategory`, a child gets that menu plus the groups meant for anyone
197
+ * (`audience: 'any'`), and an adult everything but the children's — which is
198
+ * often nothing, and they are told so.
198
199
  */
199
200
  function PartyServiceScreen({ services, categories, categoryOf, childCategory, serviceFits = allFit, party, selection, shared, }) {
200
201
  const { Card, labels, classNames } = shared;
201
202
  const fileUnder = categoryOf ?? defaultCategoryOf(categories);
203
+ const keysFor = (audience) => new Set(categories.filter((group) => group.audience === audience).map((group) => group.key));
204
+ // Children's groups: the configured one plus any group marked 'child'.
205
+ const forChildren = keysFor('child');
206
+ if (childCategory !== undefined)
207
+ forChildren.add(childCategory);
208
+ const forAnyone = keysFor('any');
202
209
  const size = party.people.length;
203
210
  const [active, setActive] = useState(0);
204
211
  // Multi-select: the engine books a grown-up's visit alongside the
@@ -212,7 +219,17 @@ function PartyServiceScreen({ services, categories, categoryOf, childCategory, s
212
219
  // note below that books the wrong thing.
213
220
  childCategory === undefined
214
221
  ? fits
215
- : fits.filter((service) => (fileUnder(service) === childCategory) === !person.adult), (service) => serviceFits(service, index));
222
+ : fits.filter((service) => {
223
+ const key = fileUnder(service);
224
+ if (forChildren.has(key))
225
+ return !person.adult;
226
+ // A child gets an «anyone» group only through an EXPLICIT mapping:
227
+ // the caller's own `categoryOf` when it passes one (its mapping is
228
+ // its decision), else the service's own category — never the
229
+ // built-in display fallback, which files an unlisted (possibly
230
+ // grown-up) category under the last group.
231
+ return person.adult || forAnyone.has(categoryOf ? key : service.category);
232
+ }), (service) => serviceFits(service, index));
216
233
  const ticked = selection ? tickedBy(selection, index) : null;
217
234
  const chosen = party.choices[index]?.id ?? null;
218
235
  const suggestion = person.suggestion ?? null;
@@ -42,6 +42,12 @@ export interface StylistOption {
42
42
  /** The avatar's fallback: initials, or a symbol for «first available». */
43
43
  badge: string;
44
44
  photoUrl?: string | null;
45
+ /**
46
+ * For «first available» with `firstAvailableFaces`: the photos of the
47
+ * stylists it can be. Two or more draw a small group in the avatar instead
48
+ * of the badge.
49
+ */
50
+ faces?: readonly string[];
45
51
  /** The formatted next opening, when there is one. */
46
52
  availability?: string | null;
47
53
  availabilityLoading?: boolean;
@@ -98,6 +104,18 @@ export interface StylistScreenProps {
98
104
  /** A polite announcement once the list is here (e.g. the restored stylist is gone). */
99
105
  notice?: string | null;
100
106
  onPick: (resourceId: string | null) => void;
107
+ /**
108
+ * Draw «first available» as the faces of the first three qualified stylists
109
+ * with a photo (in the salon's order) instead of its badge: it means «any of
110
+ * these», so it shows them, and only ones who can do the whole basket.
111
+ * Falls back to the badge with fewer than two photos.
112
+ */
113
+ firstAvailableFaces?: boolean;
114
+ /**
115
+ * On a phone, fade the right edge of the sideways row so a cut-off card reads
116
+ * as «there is more», not as a layout bug.
117
+ */
118
+ edgeFade?: boolean;
101
119
  /**
102
120
  * Present only for a family. One child gets no picker: «one after the
103
121
  * other» is not a choice when there is nobody to be after.
@@ -124,6 +142,6 @@ export interface StylistScreenProps {
124
142
  classNames?: SlotClassNames<StylistScreenSlot>;
125
143
  components?: StylistScreenComponents;
126
144
  }
127
- export declare function StylistScreen({ labels, format, now, serviceIds, resources, selectedResourceId, nextAvailableTs, nextAvailableLoading, loading, skeletonCount, pendingName, notice, onPick, party, classNames, components, }: StylistScreenProps): import("react").JSX.Element;
145
+ export declare function StylistScreen({ labels, format, now, serviceIds, resources, selectedResourceId, nextAvailableTs, nextAvailableLoading, loading, skeletonCount, pendingName, notice, onPick, firstAvailableFaces, edgeFade, party, classNames, components, }: StylistScreenProps): import("react").JSX.Element;
128
146
  /** The default stylist option: avatar (photo or badge), name(s), bio, next opening. */
129
147
  export declare function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, labels, className, selectedClassName, nextAvailableClassName, }: StylistCardProps): import("react").JSX.Element;
@@ -49,6 +49,14 @@ export const STYLIST_SCREEN_LABEL_KEYS = [
49
49
  */
50
50
  const OPTION_HEIGHT = 'h-32 md:h-24';
51
51
  const DEFAULT_SKELETON_COUNT = 5;
52
+ /** How many faces «first available» draws with `firstAvailableFaces`. */
53
+ const FACE_COUNT = 3;
54
+ /** One on top, two below, overlapping like people standing together. */
55
+ const FACE_POSITIONS = [
56
+ 'left-1/2 top-[3px] -translate-x-1/2',
57
+ 'bottom-[3px] left-[3px]',
58
+ 'bottom-[3px] right-[3px]',
59
+ ];
52
60
  const HEADING_ID = 'booking-stylist-heading';
53
61
  /** «Ada» from «Ada Demo»: a phone tile has room for one word. */
54
62
  function firstName(displayName) {
@@ -64,7 +72,7 @@ function bySize(labels, base, size, sizeWord) {
64
72
  const suffix = SIZE_WORDS[size] ?? 'other';
65
73
  return renderLabel(labels[`${base}.${suffix}`], { count: size, sizeWord: sizeWord ?? size });
66
74
  }
67
- export function StylistScreen({ labels, format, now, serviceIds, resources, selectedResourceId = null, nextAvailableTs, nextAvailableLoading = false, loading = false, skeletonCount = DEFAULT_SKELETON_COUNT, pendingName = null, notice = null, onPick, party, classNames, components, }) {
75
+ export function StylistScreen({ labels, format, now, serviceIds, resources, selectedResourceId = null, nextAvailableTs, nextAvailableLoading = false, loading = false, skeletonCount = DEFAULT_SKELETON_COUNT, pendingName = null, notice = null, onPick, firstAvailableFaces = false, edgeFade = false, party, classNames, components, }) {
68
76
  // `every`, not `some`: the step asks one question for the whole visit, so a
69
77
  // stylist who covers one of two services is not an answer — it is a refusal
70
78
  // the engine issues at submit, offered here as a choice.
@@ -88,6 +96,14 @@ export function StylistScreen({ labels, format, now, serviceIds, resources, sele
88
96
  shortName: labelText(labels['stylist.firstAvailable']),
89
97
  subtitle: labelText(labels['stylist.firstAvailableSubtitle']),
90
98
  badge: labelText(labels['stylist.firstAvailableBadge']),
99
+ ...(firstAvailableFaces && !loading
100
+ ? {
101
+ // Distinct photos: two stylists sharing one picture is one face.
102
+ faces: [
103
+ ...new Set(qualified.flatMap((resource) => (resource.photoUrl ? [resource.photoUrl] : []))),
104
+ ].slice(0, FACE_COUNT),
105
+ }
106
+ : {}),
91
107
  },
92
108
  ...(loading
93
109
  ? held
@@ -114,7 +130,7 @@ export function StylistScreen({ labels, format, now, serviceIds, resources, sele
114
130
  }), classNames: classNames })] })), party?.mode === 'parallel' ? (_jsx("p", { className: "rounded-lg border border-border bg-card px-5 py-4 text-sm", children: bySize(labels, 'stylist.party.parallelNote', party.size, party.sizeWord) })) : (_jsx(StylistRadioGroup, { options: options, selectedResourceId: selectedResourceId,
115
131
  // The held option takes one placeholder's place, so the row is
116
132
  // exactly as long as it was and nothing after it moves.
117
- skeletons: loading ? Math.max(0, skeletonCount - (options.length - 1)) : 0, busy: loading, onPick: onPick, labels: labels, classNames: classNames, Card: components?.StylistCard ?? DefaultStylistCard })), _jsx(LiveStatus, { text: loading ? labelText(labels['stylist.loading']) : notice })] }));
133
+ skeletons: loading ? Math.max(0, skeletonCount - (options.length - 1)) : 0, busy: loading, onPick: onPick, labels: labels, classNames: classNames, edgeFade: edgeFade, Card: components?.StylistCard ?? DefaultStylistCard })), _jsx(LiveStatus, { text: loading ? labelText(labels['stylist.loading']) : notice })] }));
118
134
  }
119
135
  /**
120
136
  * The options as one radiogroup with a roving tab stop.
@@ -123,7 +139,7 @@ export function StylistScreen({ labels, format, now, serviceIds, resources, sele
123
139
  * stylist is cheap and reversible, and a group that needed Space after every
124
140
  * arrow would be a list of buttons in a radiogroup's clothes.
125
141
  */
126
- function StylistRadioGroup({ options, selectedResourceId, skeletons, busy, onPick, labels, classNames, Card, }) {
142
+ function StylistRadioGroup({ options, selectedResourceId, skeletons, busy, onPick, labels, classNames, edgeFade, Card, }) {
127
143
  const groupRef = useRef(null);
128
144
  const optionRefs = useRef([]);
129
145
  const selectedIndex = options.findIndex((option) => option.resourceId === selectedResourceId);
@@ -176,7 +192,11 @@ function StylistRadioGroup({ options, selectedResourceId, skeletons, busy, onPic
176
192
  // Phone: one row of fixed height, scrolled sideways with snap; the
177
193
  // padding leaves room for the selected avatar's ring, which the scroll
178
194
  // box would otherwise clip. `md`: the column of cards.
179
- className: slotClass(classNames, 'group', 'relative -mx-1 flex h-36 snap-x snap-mandatory scroll-px-1 gap-1 overflow-x-auto overscroll-x-contain px-1 py-2', 'md:mx-0 md:h-auto md:snap-none md:flex-col md:gap-2 md:overflow-visible md:p-0'), children: [options.map((option, index) => (_jsx(Card, { ref: (node) => {
195
+ className: slotClass(classNames, 'group', 'relative -mx-1 flex h-36 snap-x snap-mandatory scroll-px-1 gap-1 overflow-x-auto overscroll-x-contain px-1 py-2',
196
+ // The last few pixels fade, so the cut-off card reads as «more this way».
197
+ // The trailing room lets the last card scroll wholly clear of the fade.
198
+ edgeFade &&
199
+ 'pr-10 [mask-image:linear-gradient(to_right,#000_calc(100%-2.5rem),transparent)] md:[mask-image:none]', 'md:mx-0 md:h-auto md:snap-none md:flex-col md:gap-2 md:overflow-visible md:p-0'), children: [options.map((option, index) => (_jsx(Card, { ref: (node) => {
180
200
  optionRefs.current[index] = node;
181
201
  }, option: option, selected: option.resourceId === selectedResourceId, tabIndex: index === tabStop ? 0 : -1, onClick: () => onPick(option.resourceId), labels: labels, className: classNames?.card, selectedClassName: classNames?.cardSelected, nextAvailableClassName: classNames?.nextAvailable }, option.resourceId ?? 'first-available'))), Array.from({ length: skeletons }, (_, index) => (_jsx(SkeletonCard, {}, index)))] }));
182
202
  }
@@ -213,7 +233,7 @@ export function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, l
213
233
  const nameId = `${id}-name`;
214
234
  const nextId = `${id}-next`;
215
235
  const bioId = `${id}-bio`;
216
- const { name, subtitle, badge, photoUrl, availability, availabilityLoading, reserveAvailability, } = option;
236
+ const { name, subtitle, badge, photoUrl, faces, availability, availabilityLoading, reserveAvailability, } = option;
217
237
  return (_jsxs("button", { ref: ref, type: "button", role: "radio", "aria-checked": selected, tabIndex: tabIndex, onClick: onClick, "aria-labelledby": availability ? `${nameId} ${nextId}` : nameId, "aria-describedby": subtitle ? bioId : undefined,
218
238
  // Ring and weight alongside the tint: the selected option has to be
219
239
  // obvious on a monochrome screen too. On a phone the ring is on the
@@ -222,7 +242,21 @@ export function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, l
222
242
  ? 'font-semibold md:border-primary md:bg-primary/10 md:ring-2 md:ring-primary'
223
243
  : 'md:border-border md:bg-card md:hover:border-primary', className, selected && selectedClassName), children: [_jsx("span", { "aria-hidden": "true", "data-testid": "stylist-avatar", className: cn('flex size-14 shrink-0 items-center justify-center overflow-hidden rounded-full bg-secondary/30 font-semibold md:size-12', selected
224
244
  ? 'ring-2 ring-primary ring-offset-2 ring-offset-background md:ring-0 md:ring-offset-0'
225
- : 'ring-1 ring-border md:ring-0'), children: photoUrl ? (_jsx("img", { src: photoUrl, alt: "", className: "size-full object-cover", width: 56, height: 56, loading: "lazy", decoding: "async" })) : (badge) }), _jsxs("span", { className: "flex w-full min-w-0 flex-col md:w-auto", children: [_jsx("span", { "aria-hidden": "true", className: "h-5 truncate text-sm font-medium md:hidden", children: option.shortName }), _jsx("span", { id: nameId, className: "hidden truncate font-medium md:block", children: name }), subtitle && (_jsx("span", { id: bioId, className: "hidden truncate text-sm text-muted-foreground md:block", children: subtitle })), availability ? (_jsxs("span", { id: nextId, "data-testid": "next-available-row", className: cn('line-clamp-2 h-8 text-xs leading-4 text-foreground tabular-nums md:line-clamp-1 md:h-5 md:text-sm md:leading-5', nextAvailableClassName), children: [_jsx("span", { className: "sr-only md:not-sr-only", children: labels['stylist.nextAvailable'] }), ' ', availability] })) : (reserveAvailability && (_jsx("span", { "data-testid": "next-available-row", "aria-hidden": "true", className: "flex h-8 justify-center pt-1 md:block md:h-5 md:py-1", children: availabilityLoading && (_jsx("span", { className: "block h-3 w-16 animate-pulse rounded bg-muted md:w-36" })) })))] })] }));
245
+ : 'ring-1 ring-border md:ring-0'), children: _jsx(StylistAvatarContent, { photoUrl: photoUrl, faces: faces, badge: badge }) }), _jsxs("span", { className: "flex w-full min-w-0 flex-col md:w-auto", children: [_jsx("span", { "aria-hidden": "true", className: "h-5 truncate text-sm font-medium md:hidden", children: option.shortName }), _jsx("span", { id: nameId, className: "hidden truncate font-medium md:block", children: name }), subtitle && (_jsx("span", { id: bioId, className: "hidden truncate text-sm text-muted-foreground md:block", children: subtitle })), availability ? (_jsxs("span", { id: nextId, "data-testid": "next-available-row", className: cn('line-clamp-2 h-8 text-xs leading-4 text-foreground tabular-nums md:line-clamp-1 md:h-5 md:text-sm md:leading-5', nextAvailableClassName), children: [_jsx("span", { className: "sr-only md:not-sr-only", children: labels['stylist.nextAvailable'] }), ' ', availability] })) : (reserveAvailability && (_jsx("span", { "data-testid": "next-available-row", "aria-hidden": "true", className: "flex h-8 justify-center pt-1 md:block md:h-5 md:py-1", children: availabilityLoading && (_jsx("span", { className: "block h-3 w-16 animate-pulse rounded bg-muted md:w-36" })) })))] })] }));
246
+ }
247
+ /**
248
+ * What fills the avatar circle: the stylist's photo, a small group of faces
249
+ * for «first available» (`firstAvailableFaces`), or the badge.
250
+ */
251
+ function StylistAvatarContent({ photoUrl, faces, badge, }) {
252
+ if (photoUrl) {
253
+ return (_jsx("img", { src: photoUrl, alt: "", className: "size-full object-cover", width: 56, height: 56, loading: "lazy", decoding: "async" }));
254
+ }
255
+ if (faces && faces.length >= 2) {
256
+ // A small group, for «any of these»: the faces of who it can be.
257
+ return (_jsx("span", { "data-testid": "stylist-faces", className: "relative size-full", children: faces.slice(0, FACE_POSITIONS.length).map((src, index) => (_jsx("img", { src: src, alt: "", width: 30, height: 30, decoding: "async", className: cn('absolute size-[30px] rounded-full object-cover ring-2 ring-background md:size-[26px]', FACE_POSITIONS[index]) }, src))) }));
258
+ }
259
+ return badge;
226
260
  }
227
261
  /** A stylist option before there is a stylist: the same box, the same circle, at both widths. */
228
262
  function SkeletonCard() {
@@ -10,18 +10,36 @@ import type { WizardStep } from './types.js';
10
10
  * wizard it summarises.
11
11
  */
12
12
  export declare const SUMMARY_BAR_LABEL_KEYS: readonly ["summary.placeholder.who", "summary.placeholder.service", "summary.next"];
13
- export type SummaryBarLabels = Record<(typeof SUMMARY_BAR_LABEL_KEYS)[number], BookingLabel>;
14
- /** `root` the bar, `line` the summary sentence, `next` the button. */
15
- export type SummaryBarSlot = 'root' | 'line' | 'next';
13
+ /**
14
+ * Opt-in: what the bar says in place of a greyed-out button, per step, when
15
+ * `hideNextWhenDisabled` is on — the step moves on by itself once answered,
16
+ * so the useful words are what to tap (e.g. «Tap a time»). Absent: nothing.
17
+ */
18
+ export declare const SUMMARY_BAR_HINT_LABEL_KEYS: readonly ["summary.hint.who", "summary.hint.service", "summary.hint.when"];
19
+ export type SummaryBarLabels = Record<(typeof SUMMARY_BAR_LABEL_KEYS)[number], BookingLabel> & Partial<Record<(typeof SUMMARY_BAR_HINT_LABEL_KEYS)[number], BookingLabel>>;
20
+ /** `root` the bar, `line` the summary sentence, `detail` its second line, `next` the button, `hint` the words in its place. */
21
+ export type SummaryBarSlot = 'root' | 'line' | 'detail' | 'next' | 'hint';
16
22
  export interface SummaryBarProps {
17
23
  /** The summary sentence, e.g. «Service · Stylist · today 15:00 · 490 kr». Empty = nothing chosen. */
18
24
  line: string;
25
+ /**
26
+ * A second, smaller line under it (e.g. «today 15:00 · 490 kr»), so the part
27
+ * a parent checks is not the part an ellipsis eats on a phone. The bar keeps
28
+ * its one fixed height either way.
29
+ */
30
+ detail?: string;
19
31
  /** Whether the current step can be left. */
20
32
  canAdvance: boolean;
21
33
  /** The current step. The bar renders nothing on `details`, which has its own submit. */
22
34
  step: WizardStep;
23
35
  onNext: () => void;
36
+ /**
37
+ * Draw the button only while it does something. For a step that moves on by
38
+ * itself once answered, a disabled «next» is the loudest thing on the screen
39
+ * and reads as broken; the step's `summary.hint.*` label stands in its place.
40
+ */
41
+ hideNextWhenDisabled?: boolean;
24
42
  labels: SummaryBarLabels;
25
43
  classNames?: SlotClassNames<SummaryBarSlot>;
26
44
  }
27
- export declare function SummaryBar({ line, canAdvance, step, onNext, labels, classNames, }: SummaryBarProps): import("react").JSX.Element | null;
45
+ export declare function SummaryBar({ line, detail, canAdvance, step, onNext, hideNextWhenDisabled, labels, classNames, }: SummaryBarProps): import("react").JSX.Element | null;
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { renderLabel } from './internal/label-parts.js';
3
4
  import { BookingButton } from './internal/ui.js';
4
5
  import { slotClass } from './slots.js';
5
6
  /**
@@ -17,16 +18,34 @@ export const SUMMARY_BAR_LABEL_KEYS = [
17
18
  'summary.placeholder.service',
18
19
  'summary.next',
19
20
  ];
20
- export function SummaryBar({ line, canAdvance, step, onNext, labels, classNames, }) {
21
+ /**
22
+ * Opt-in: what the bar says in place of a greyed-out button, per step, when
23
+ * `hideNextWhenDisabled` is on — the step moves on by itself once answered,
24
+ * so the useful words are what to tap (e.g. «Tap a time»). Absent: nothing.
25
+ */
26
+ export const SUMMARY_BAR_HINT_LABEL_KEYS = [
27
+ 'summary.hint.who',
28
+ 'summary.hint.service',
29
+ 'summary.hint.when',
30
+ ];
31
+ /** Which hint each step shows. */
32
+ const HINT_KEYS = {
33
+ who: 'summary.hint.who',
34
+ service: 'summary.hint.service',
35
+ when: 'summary.hint.when',
36
+ };
37
+ export function SummaryBar({ line, detail, canAdvance, step, onNext, hideNextWhenDisabled = false, labels, classNames, }) {
21
38
  // Gone on the details step, not merely relabelled: that step's own button
22
39
  // carries the price and the commitment, and two sticky submits stacked at the
23
40
  // bottom of a phone is the outcome to avoid.
24
41
  if (step === 'details')
25
42
  return null;
43
+ const hintKey = HINT_KEYS[step];
44
+ const hint = hideNextWhenDisabled && !canAdvance && hintKey ? labels[hintKey] : undefined;
26
45
  // There from the first step at one fixed height, rather than appearing on
27
46
  // the first tap and pushing the page down under the visitor's thumb.
28
- return (_jsxs("div", { className: slotClass(classNames, 'root', 'sticky bottom-0 z-40 flex h-16 items-center justify-between gap-4 border-t border-border bg-background/95 px-5 backdrop-blur-md'), children: [_jsx("p", { className: slotClass(classNames, 'line', 'min-w-0 truncate text-sm font-medium', !line && 'text-muted-foreground'), children: line ||
29
- (step === 'who'
30
- ? labels['summary.placeholder.who']
31
- : labels['summary.placeholder.service']) }), _jsx(BookingButton, { size: "lg", className: slotClass(classNames, 'next'), onClick: onNext, disabled: !canAdvance, children: labels['summary.next'] })] }));
47
+ return (_jsxs("div", { className: slotClass(classNames, 'root', 'sticky bottom-0 z-40 flex h-16 items-center justify-between gap-4 border-t border-border bg-background/95 px-5 backdrop-blur-md'), children: [_jsxs("div", { className: "flex min-w-0 flex-col", children: [_jsx("p", { className: slotClass(classNames, 'line', 'min-w-0 truncate text-sm font-medium', !line && 'text-muted-foreground'), children: line ||
48
+ (step === 'who'
49
+ ? labels['summary.placeholder.who']
50
+ : labels['summary.placeholder.service']) }), line && detail && (_jsx("p", { className: slotClass(classNames, 'detail', 'min-w-0 truncate text-xs text-muted-foreground tabular-nums'), children: detail }))] }), hideNextWhenDisabled && !canAdvance ? (hint && (_jsx("p", { className: slotClass(classNames, 'hint', 'shrink-0 text-sm font-medium text-muted-foreground'), children: renderLabel(hint) }))) : (_jsx(BookingButton, { size: "lg", className: slotClass(classNames, 'next'), onClick: onNext, disabled: !canAdvance, children: labels['summary.next'] }))] }));
32
51
  }
@@ -27,7 +27,13 @@ export declare const TIME_SCREEN_LABEL_KEYS: readonly ["time.heading", "time.day
27
27
  * finishes (end them with the dash); `closed` and `over` are whole sentences.
28
28
  * - `time.empty.callLink|callLinkAria` `{phone}`
29
29
  */
30
- export type TimeScreenLabels = Record<(typeof TIME_SCREEN_LABEL_KEYS)[number], BookingLabel>;
30
+ /**
31
+ * Opt-in keys, for the `soonest` row and `dayFullness`. Placeholders:
32
+ * `time.soonest.pick` `{day}` `{time}`; `time.soonest.with` `{stylist}`;
33
+ * `time.dayChip.free` `{count}`.
34
+ */
35
+ export declare const TIME_SCREEN_FINDER_LABEL_KEYS: readonly ["time.soonest.heading", "time.soonest.pick", "time.soonest.with", "time.dayChip.free", "time.dayChip.freeOne", "time.dayChip.full"];
36
+ export type TimeScreenLabels = Record<(typeof TIME_SCREEN_LABEL_KEYS)[number], BookingLabel> & Partial<Record<(typeof TIME_SCREEN_FINDER_LABEL_KEYS)[number], BookingLabel>>;
31
37
  /**
32
38
  * `classNames` slots:
33
39
  * - `root` the section · `heading` the h2
@@ -39,7 +45,7 @@ export type TimeScreenLabels = Record<(typeof TIME_SCREEN_LABEL_KEYS)[number], B
39
45
  * - `parallel` the «both at once» offer · `empty` the nothing-free card
40
46
  * - `nextFree` the «next free time» link-button · `shortNotice` the short-notice line
41
47
  */
42
- export type TimeScreenSlot = 'root' | 'heading' | 'dayStrip' | 'dayChip' | 'dayChipSelected' | 'month' | 'monthCell' | 'monthCellSelected' | 'surcharge' | 'daypart' | 'daypartHeading' | 'chip' | 'chipNearest' | 'chipCurrent' | 'parallel' | 'empty' | 'nextFree' | 'shortNotice';
48
+ export type TimeScreenSlot = 'root' | 'heading' | 'dayStrip' | 'dayChip' | 'dayChipSelected' | 'month' | 'monthCell' | 'monthCellSelected' | 'surcharge' | 'daypart' | 'daypartHeading' | 'chip' | 'chipNearest' | 'chipCurrent' | 'parallel' | 'empty' | 'nextFree' | 'shortNotice' | 'soonest';
43
49
  export interface DayChipProps {
44
50
  /** An instant inside the day. */
45
51
  dayTs: number;
@@ -49,9 +55,18 @@ export interface DayChipProps {
49
55
  /** The resolved classes (base + selected + slot overrides). */
50
56
  className: string;
51
57
  onSelect: () => void;
58
+ /**
59
+ * With `dayFullness`: how many starts the day has free (0 = full). Absent
60
+ * otherwise, and the chip is the plain one-line chip.
61
+ */
62
+ free?: number;
63
+ /** With `free`: what the marks say, read out (e.g. «4 free», «full»). */
64
+ freeLabel?: string;
52
65
  }
66
+ /** How full a day looks, as filled marks out of three: 1–3 free, 4–7, 8 and more. */
67
+ export declare function dayFreeMarks(free: number): 0 | 1 | 2 | 3;
53
68
  /** One chip of the day strip: an `aria-pressed` outline button. */
54
- export declare function DefaultDayChip({ label, selected, className, onSelect }: DayChipProps): import("react").JSX.Element;
69
+ export declare function DefaultDayChip({ label, selected, className, onSelect, free, freeLabel, }: DayChipProps): import("react").JSX.Element;
55
70
  export interface TimeChipProps {
56
71
  startTs: number;
57
72
  /** «14:30», a party's «15:00 → 16:00», or the current hour's «15:00 · Current time». */
@@ -146,6 +161,25 @@ export interface TimeScreenProps {
146
161
  onPick: (slot: BookingSlotDto) => void;
147
162
  /** A family. Present, its slots replace `slots` entirely. */
148
163
  party?: TimeScreenParty;
164
+ /**
165
+ * A «free soon» row above the days: the `count` (default 3) earliest starts
166
+ * across the whole window, one tap each, so «when is the first time?» is
167
+ * answered before any day is chosen. `resolveStylist` names who each would
168
+ * be with (the stylist `onPick` books). Not drawn for a family or a move.
169
+ * Needs the `time.soonest.*` labels.
170
+ */
171
+ soonest?: {
172
+ count?: number;
173
+ resolveStylist?: (resourceId: string) => {
174
+ name: string;
175
+ photoUrl?: string | null;
176
+ } | null;
177
+ };
178
+ /**
179
+ * Draw each day chip with how many starts it has free (marks, or «full»),
180
+ * as a 44 px target. Needs the `time.dayChip.*` labels.
181
+ */
182
+ dayFullness?: boolean;
149
183
  /** «Now», for today / tomorrow and whether today is over. Defaults to `Date.now()`. */
150
184
  now?: number;
151
185
  classNames?: SlotClassNames<TimeScreenSlot>;
@@ -160,7 +194,7 @@ export interface TimeScreenProps {
160
194
  * - `open` — open, with a start still ahead.
161
195
  */
162
196
  export type DayStanding = 'unknown' | 'closed' | 'over' | 'open';
163
- export declare function TimeScreen({ labels, format, dayparts, slots, days, openDays, stylistName, takenSlotTs, phone, weekendNote, currentSlotTs, monthView, onPick, party, now: nowProp, classNames, components, }: TimeScreenProps): import("react").JSX.Element;
197
+ export declare function TimeScreen({ labels, format, dayparts, slots, days, openDays, stylistName, takenSlotTs, phone, weekendNote, currentSlotTs, monthView, onPick, party, soonest, dayFullness, now: nowProp, classNames, components, }: TimeScreenProps): import("react").JSX.Element;
164
198
  export interface TakenToastProps {
165
199
  labels: Pick<TimeScreenLabels, 'time.taken.message' | 'time.taken.close'>;
166
200
  /** The instant that was just lost; each new one is a fresh toast. */
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, X } from 'lucide-react';
4
4
  import { useEffect, useState } from 'react';
5
5
  import { cn } from '../lib/utils.js';
@@ -64,9 +64,58 @@ export const TIME_SCREEN_LABEL_KEYS = [
64
64
  'time.taken.message',
65
65
  'time.taken.close',
66
66
  ];
67
+ /**
68
+ * Copy for the time step. Placeholders:
69
+ * - `time.current` `{time}`; `time.weekend.pct` `{pct}` `{price}`; `time.weekend.mixed` `{price}`
70
+ * (keep a non-breaking space inside «+10 %» and the price so neither wraps)
71
+ * - `time.month.heading` `{month}` `{year}`
72
+ * - `time.party.sequential` `{start}` `{end}`; `time.party.parallel` `{time}` `{seats}`;
73
+ * `time.party.seat` `{who}` `{stylist}`
74
+ * - `time.parallel.offer.*` `{day}` `{time}` (`two` for two people, `other` for more);
75
+ * `time.parallel.accept` `{time}`
76
+ * - `time.empty.closed|over|full|unknown` `{day}`; `time.empty.stylist` `{stylist}` `{day}`.
77
+ * `full`, `stylist` and `unknown` are the lead of a sentence the phone line
78
+ * finishes (end them with the dash); `closed` and `over` are whole sentences.
79
+ * - `time.empty.callLink|callLinkAria` `{phone}`
80
+ */
81
+ /**
82
+ * Opt-in keys, for the `soonest` row and `dayFullness`. Placeholders:
83
+ * `time.soonest.pick` `{day}` `{time}`; `time.soonest.with` `{stylist}`;
84
+ * `time.dayChip.free` `{count}`.
85
+ */
86
+ export const TIME_SCREEN_FINDER_LABEL_KEYS = [
87
+ /** Heading of the soonest-times row, e.g. «Free soon». */
88
+ 'time.soonest.heading',
89
+ /** Accessible name of a soonest card: `{day}` `{time}`, e.g. «{day} at {time}». */
90
+ 'time.soonest.pick',
91
+ /** Appended to it when the card names a stylist: `{stylist}`, e.g. «with {stylist}». */
92
+ 'time.soonest.with',
93
+ /** Screen-reader text of a day chip with openings: `{count}`, e.g. «{count} free». */
94
+ 'time.dayChip.free',
95
+ /** The same for exactly one opening, where the language needs it (nb «1 ledig»). Falls back to `free`. */
96
+ 'time.dayChip.freeOne',
97
+ /** A day chip with none, shown and read, e.g. «full». */
98
+ 'time.dayChip.full',
99
+ ];
100
+ /** How full a day looks, as filled marks out of three: 1–3 free, 4–7, 8 and more. */
101
+ export function dayFreeMarks(free) {
102
+ if (free <= 0)
103
+ return 0;
104
+ if (free <= 3)
105
+ return 1;
106
+ if (free <= 7)
107
+ return 2;
108
+ return 3;
109
+ }
67
110
  /** One chip of the day strip: an `aria-pressed` outline button. */
68
- export function DefaultDayChip({ label, selected, className, onSelect }) {
69
- return (_jsx(BookingButton, { variant: "outline", size: "sm", "aria-pressed": selected, onClick: onSelect, className: className, children: label }));
111
+ export function DefaultDayChip({ label, selected, className, onSelect, free, freeLabel, }) {
112
+ if (free === undefined) {
113
+ return (_jsx(BookingButton, { variant: "outline", size: "sm", "aria-pressed": selected, onClick: onSelect, className: className, children: label }));
114
+ }
115
+ const marks = dayFreeMarks(free);
116
+ // Two lines and a 44 px target: the day, and under it how much room it has,
117
+ // so a full day is seen before it is tapped rather than after.
118
+ return (_jsxs(BookingButton, { variant: "outline", size: "sm", "aria-pressed": selected, onClick: onSelect, className: cn('h-auto min-h-11 flex-col gap-1 py-1.5', free === 0 && !selected && 'text-muted-foreground', className), children: [_jsx("span", { children: label }), free === 0 && freeLabel ? (_jsx("span", { className: "text-[11px] font-medium leading-none", children: freeLabel })) : (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", "data-testid": "day-free-marks", className: "flex gap-0.5", children: [1, 2, 3].map((mark) => (_jsx("span", { "data-filled": mark <= marks ? '' : undefined, className: cn('block h-[3px] w-2 rounded-full bg-current', mark > marks && 'opacity-25') }, mark))) }), freeLabel && _jsx("span", { className: "sr-only", children: freeLabel })] }))] }));
70
119
  }
71
120
  /** One time chip. */
72
121
  export function DefaultTimeChip({ label, nearest, nearestLabel, current, className, onPick, }) {
@@ -209,7 +258,7 @@ function monthGrid(clock, ts) {
209
258
  // ---------------------------------------------------------------------------
210
259
  // TimeScreen
211
260
  // ---------------------------------------------------------------------------
212
- export function TimeScreen({ labels, format, dayparts, slots, days, openDays = null, stylistName = null, takenSlotTs = null, phone = null, weekendNote, currentSlotTs = null, monthView = false, onPick, party, now: nowProp, classNames, components, }) {
261
+ export function TimeScreen({ labels, format, dayparts, slots, days, openDays = null, stylistName = null, takenSlotTs = null, phone = null, weekendNote, currentSlotTs = null, monthView = false, onPick, party, soonest, dayFullness = false, now: nowProp, classNames, components, }) {
213
262
  const now = nowProp ?? Date.now();
214
263
  const { clock } = format;
215
264
  const DayChip = components?.DayChip ?? DefaultDayChip;
@@ -221,6 +270,22 @@ export function TimeScreen({ labels, format, dayparts, slots, days, openDays = n
221
270
  const offered = currentSlotTs !== null && !bookable.some((slot) => slot.startTs === currentSlotTs)
222
271
  ? [...bookable, { startTs: currentSlotTs, resourceId: null }]
223
272
  : bookable;
273
+ // Free starts per day, for the fullness marks (the current-hour marker is not free).
274
+ const freeByDay = new Map();
275
+ if (dayFullness) {
276
+ for (const slot of bookable) {
277
+ if (slot.startTs === currentSlotTs)
278
+ continue;
279
+ const key = clock.dayKey(slot.startTs);
280
+ freeByDay.set(key, (freeByDay.get(key) ?? 0) + 1);
281
+ }
282
+ }
283
+ const soonestSlots = soonest && !party && currentSlotTs === null
284
+ ? [...bookable]
285
+ .filter((slot) => slot.startTs >= now)
286
+ .sort((a, b) => a.startTs - b.startTs)
287
+ .slice(0, soonest.count ?? 3)
288
+ : [];
224
289
  const partyByStart = new Map(party?.slots.map((slot) => [slot.startTs, slot]) ?? []);
225
290
  const offeredDays = buildDayStrip(clock, offered, days, takenSlotTs, currentSlotTs, openDays, now);
226
291
  // Read back through the offered days rather than held as a day of its own,
@@ -273,11 +338,67 @@ export function TimeScreen({ labels, format, dayparts, slots, days, openDays = n
273
338
  const alternative = party !== undefined && party.mode === 'sequential'
274
339
  ? (party.alternativeFor?.(selectedDay) ?? null)
275
340
  : null;
276
- return (_jsxs("section", { "aria-labelledby": "booking-time-heading", className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx(TimeHeading, { labels: labels, className: classNames?.heading }), strip.length > 1 && (_jsxs("fieldset", { className: slotClass(classNames, 'dayStrip', 'flex flex-wrap gap-2'), children: [_jsx("legend", { className: "sr-only", children: labels['time.dayStrip.legend'] }), strip.map((day) => {
341
+ return (_jsxs("section", { "aria-labelledby": "booking-time-heading", className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx(TimeHeading, { labels: labels, className: classNames?.heading }), soonestSlots.length > 0 && (_jsx(SoonestRow, { labels: labels, format: format, slots: soonestSlots, now: now, resolveStylist: soonest?.resolveStylist, weekendNote: weekendNote, onPick: onPick, className: classNames?.soonest })), strip.length > 1 && (_jsxs("fieldset", { className: slotClass(classNames, 'dayStrip', 'flex flex-wrap gap-2'), children: [_jsx("legend", { className: "sr-only", children: labels['time.dayStrip.legend'] }), strip.map((day) => {
277
342
  const selected = clock.dayKey(day) === selectedKey;
278
- return (_jsx(DayChip, { dayTs: day, label: clock.dayChip(day, now), selected: selected, onSelect: () => setTappedDayKey(clock.dayKey(day)), className: cn('rounded-full', selected ? 'border-primary bg-primary/10 font-bold ring-2 ring-primary' : '', classNames?.dayChip, selected ? classNames?.dayChipSelected : undefined) }, clock.dayKey(day)));
343
+ return (_jsx(DayChip, { dayTs: day, label: clock.dayChip(day, now), selected: selected, onSelect: () => setTappedDayKey(clock.dayKey(day)), ...(dayFullness
344
+ ? dayFreeProps(labels, freeByDay.get(clock.dayKey(day)) ?? 0,
345
+ // «Full» only when it is true of the business: an open
346
+ // day, asked for everyone — not hours that could not be
347
+ // read, nor a day one named stylist does not work, nor a
348
+ // family's day (the other mode may still seat them).
349
+ !party &&
350
+ stylistName === null &&
351
+ dayStanding(clock, day, openDays, now) === 'open')
352
+ : {}), className: cn('rounded-full', selected ? 'border-primary bg-primary/10 font-bold ring-2 ring-primary' : '', classNames?.dayChip, selected ? classNames?.dayChipSelected : undefined) }, clock.dayKey(day)));
279
353
  })] })), monthView && (_jsx(MonthCalendar, { labels: labels, clock: clock, open: showMonth, onToggle: () => setShowMonth((shown) => !shown), monthTs: monthTs ?? selectedDay, onMonth: setMonthTs, strip: offeredDays, selectedDay: selectedDay, now: now, onPickDay: (day) => setTappedDayKey(clock.dayKey(day)), classNames: classNames })), surcharge !== null && (_jsx("p", { "data-testid": "surcharge-row", className: slotClass(classNames, 'surcharge', 'min-h-5 text-sm text-muted-foreground'), children: clock.isWeekend(selectedDay) ? weekendSentence(labels, format, surcharge) : null })), dayBookable.length === 0 && alternative && party && (_jsx(ParallelInstead, { labels: labels, clock: clock, slot: alternative, day: selectedDay, now: now, size: party.items.length, onAccept: () => party.onPick(alternative), className: classNames?.parallel })), dayBookable.length === 0 && !(alternative && party) && (_jsx(NothingFreeCard, { labels: labels, format: format, day: selectedDay, now: now, phone: phone, standing: dayStanding(clock, selectedDay, openDays, now), stylistName: stylistName, className: classNames?.empty })), dayBookable.length > 0 && (_jsx(DaypartSections, { labels: labels, clock: clock, dayparts: dayparts, daySlots: daySlots, nearest: nearest, currentSlotTs: currentSlotTs, party: party, partyByStart: partyByStart, onPick: onPick, classNames: classNames, TimeChip: components?.TimeChip ?? DefaultTimeChip })), elsewhere && (_jsx(BookingButton, { variant: "link", onClick: () => setTappedDayKey(clock.dayKey(elsewhere.startTs)), className: classNames?.nextFree, children: labels['time.nextFree'] })), isToday && dayBookable.length > 0 && (_jsx(ShortNoticeLine, { labels: labels, format: format, phone: phone, className: classNames?.shortNotice }))] }));
280
354
  }
355
+ /** The weekend note as one plain sentence, for a card that books without the notice on screen. */
356
+ function weekendSentenceText(labels, format, note) {
357
+ const price = format.price(note.priceOre);
358
+ return note.pct === null
359
+ ? fillLabel(labels['time.weekend.mixed'], { price })
360
+ : fillLabel(labels['time.weekend.pct'], { pct: note.pct, price });
361
+ }
362
+ /** A day chip's `free` and `freeLabel`. */
363
+ function dayFreeProps(labels, free, canClaimFull) {
364
+ let label;
365
+ if (free === 0)
366
+ label = canClaimFull ? labels['time.dayChip.full'] : undefined;
367
+ else if (free === 1)
368
+ label = labels['time.dayChip.freeOne'] ?? labels['time.dayChip.free'];
369
+ else
370
+ label = labels['time.dayChip.free'];
371
+ return { free, freeLabel: label ? fillLabel(label, { count: free }) : '' };
372
+ }
373
+ /**
374
+ * «Free soon»: the earliest starts across the window as cards (the day, the
375
+ * time, and who it would be with), each booking exactly what `onPick` books
376
+ * for that start.
377
+ */
378
+ function SoonestRow({ labels, format, slots, now, resolveStylist, weekendNote, onPick, className, }) {
379
+ const { clock } = format;
380
+ const heading = labels['time.soonest.heading'];
381
+ const pick = labels['time.soonest.pick'];
382
+ const withWho = labels['time.soonest.with'];
383
+ return (_jsxs("section", { "aria-label": heading ? fillLabel(heading, {}) : undefined, className: cn('space-y-2', className), children: [heading && (_jsx("p", { "aria-hidden": "true", className: "text-sm font-semibold text-muted-foreground", children: renderLabel(heading) })), _jsx("ul", { className: "grid grid-cols-3 gap-2", children: slots.map((slot) => {
384
+ const stylist = slot.resourceId !== null && resolveStylist ? resolveStylist(slot.resourceId) : null;
385
+ const day = clock.dayChip(slot.startTs, now);
386
+ const time = clock.formatTime(slot.startTs);
387
+ const name = stylist ? format.stylistName(stylist.name) : null;
388
+ // A weekend card says its surcharge itself: the notice under the
389
+ // days only speaks for the day on screen, and a card books without it.
390
+ const note = clock.isWeekend(slot.startTs) ? (weekendNote?.(slot.startTs) ?? null) : null;
391
+ const surcharge = note ? weekendSentenceText(labels, format, note) : null;
392
+ const ariaLabel = [
393
+ pick ? fillLabel(pick, { day, time }) : `${day} ${time}`,
394
+ name && withWho ? fillLabel(withWho, { stylist: name }) : null,
395
+ surcharge,
396
+ ]
397
+ .filter(Boolean)
398
+ .join(' ');
399
+ return (_jsx("li", { className: "min-w-0", children: _jsxs("button", { type: "button", "aria-label": ariaLabel, onClick: () => onPick(slot), className: cn('flex min-h-11 w-full flex-col items-start gap-0.5 rounded-lg border border-border bg-card px-3 py-2.5 text-left', 'transition-colors hover:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'), children: [_jsx("span", { className: "max-w-full truncate text-xs font-medium text-muted-foreground", children: day }), _jsx("span", { className: "font-sans text-xl font-bold leading-tight text-primary tabular-nums", children: time }), name && (_jsxs("span", { className: "flex min-w-0 max-w-full items-center gap-1.5 text-xs", children: [stylist?.photoUrl && (_jsx("img", { src: stylist.photoUrl, alt: "", width: 20, height: 20, decoding: "async", className: "size-5 shrink-0 rounded-full object-cover" })), _jsx("span", { className: "truncate", children: name })] })), surcharge && (_jsx("span", { "data-testid": "soonest-surcharge", className: "max-w-full text-[11px] leading-tight text-muted-foreground", children: surcharge }))] }) }, slot.startTs));
400
+ }) })] }));
401
+ }
281
402
  function TimeHeading({ labels, className, }) {
282
403
  return (_jsx("h2", { id: "booking-time-heading", tabIndex: -1, className: cn('font-sans text-2xl font-bold outline-none md:text-3xl', className), children: labels['time.heading'] }));
283
404
  }
@@ -36,7 +36,13 @@ import type { NewChild, SaveResult, WizardPerson } from './types.js';
36
36
  * from the sheet, grows the list by whole rows and moves nothing sideways.
37
37
  */
38
38
  export declare const WHO_SCREEN_LABEL_KEYS: readonly ["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", "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"];
39
- export type WhoScreenLabels = Record<(typeof WHO_SCREEN_LABEL_KEYS)[number], BookingLabel> & AddChildSheetLabels;
39
+ /**
40
+ * Opt-in keys for `guestParty`. `who.party.fewer` / `who.party.more` are the
41
+ * stepper buttons' accessible names; `who.party.count` `{count}` is read out
42
+ * when the number changes.
43
+ */
44
+ export declare const WHO_SCREEN_PARTY_LABEL_KEYS: readonly ["who.party.children", "who.party.childrenNote", "who.party.adult", "who.party.adultNote", "who.party.fewer", "who.party.more", "who.party.count"];
45
+ export type WhoScreenLabels = Record<(typeof WHO_SCREEN_LABEL_KEYS)[number], BookingLabel> & Partial<Record<(typeof WHO_SCREEN_PARTY_LABEL_KEYS)[number], BookingLabel>> & AddChildSheetLabels;
40
46
  /**
41
47
  * - `root` — the `<section>`
42
48
  * - `heading` — the `<h2>`
@@ -110,6 +116,22 @@ export interface WhoScreenProps {
110
116
  * exist (or with a sentence for the sheet); a guest's is local.
111
117
  */
112
118
  onAddChild: (child: NewChild) => Promise<SaveResult>;
119
+ /**
120
+ * A guest picks how many children and whether they come too, instead of the
121
+ * chips: a stepper (1 child to begin with) and a «me too» row, up to
122
+ * `maxPeople` in all. Each change is a live answer (`advance: false`); the
123
+ * caller's «next» moves on. `child(seat)` is the 1-based child seat the chips
124
+ * use; `adult` the grown-up's seat. Named children from the add-child
125
+ * sheet keep their seats. Needs the `who.party.*` labels: without
126
+ * `children`, `adult`, `fewer`, `more` and `count` the chips are drawn
127
+ * instead, rather than unnamed controls.
128
+ */
129
+ guestParty?: {
130
+ child: (seat: number) => WizardPerson;
131
+ adult: WizardPerson & {
132
+ adult: true;
133
+ };
134
+ };
113
135
  /** Shown under the heading for a guest — e.g. an offer to log in. */
114
136
  loginRow?: ReactNode;
115
137
  /** Newest birth year the sheet offers (see `AddChildSheet`). */
@@ -117,6 +139,6 @@ export interface WhoScreenProps {
117
139
  classNames?: SlotClassNames<WhoScreenSlot>;
118
140
  components?: WhoScreenComponents;
119
141
  }
120
- export declare function WhoScreen({ labels, format, people, family, guestChoices, addedChildren, maxPeople, selfKey, isGuestSeat, onChoose, onAddChild, loginRow, currentYear, classNames, components, }: WhoScreenProps): import("react").JSX.Element;
142
+ export declare function WhoScreen({ labels, format, people, family, guestChoices, addedChildren, maxPeople, selfKey, isGuestSeat, onChoose, onAddChild, guestParty, loginRow, currentYear, classNames, components, }: WhoScreenProps): import("react").JSX.Element;
121
143
  /** The default person card: an initial on a tinted avatar, the name, a line, a tick. */
122
144
  export declare function DefaultPersonCard({ name, line, index, selected, disabled, describedBy, onToggle, className, selectedClassName, }: PersonCardProps): import("react").JSX.Element;
@@ -1,11 +1,11 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Check } from 'lucide-react';
4
- import { useRef } from 'react';
3
+ import { Check, Minus, Plus } from 'lucide-react';
4
+ import { useEffect, useRef } from 'react';
5
5
  import { cn } from '../lib/utils.js';
6
6
  import { ADD_CHILD_SHEET_LABEL_KEYS, AddChildSheet, } from './add-child-sheet.js';
7
7
  import { renderLabel } from './internal/label-parts.js';
8
- import { labelText } from './labels.js';
8
+ import { fillLabel, labelText } from './labels.js';
9
9
  import { slotClass } from './slots.js';
10
10
  /**
11
11
  * The first step — who is the visit for?
@@ -50,6 +50,33 @@ export const WHO_SCREEN_LABEL_KEYS = [
50
50
  'who.family.limit',
51
51
  ...ADD_CHILD_SHEET_LABEL_KEYS,
52
52
  ];
53
+ /**
54
+ * Opt-in keys for `guestParty`. `who.party.fewer` / `who.party.more` are the
55
+ * stepper buttons' accessible names; `who.party.count` `{count}` is read out
56
+ * when the number changes.
57
+ */
58
+ export const WHO_SCREEN_PARTY_LABEL_KEYS = [
59
+ /** «Children». */
60
+ 'who.party.children',
61
+ /** Under it, e.g. «0–12 years». */
62
+ 'who.party.childrenNote',
63
+ /** The grown-up's row, e.g. «I'm getting a cut too». */
64
+ 'who.party.adult',
65
+ /** Under it, e.g. «Side by side, at the same time». */
66
+ 'who.party.adultNote',
67
+ 'who.party.fewer',
68
+ 'who.party.more',
69
+ /** `{count}` — the children's number, read out, e.g. «{count} children». */
70
+ 'who.party.count',
71
+ ];
72
+ /** The `who.party.*` keys `guestParty` cannot draw without; the two notes are extras. */
73
+ const PARTY_REQUIRED_LABEL_KEYS = [
74
+ 'who.party.children',
75
+ 'who.party.adult',
76
+ 'who.party.fewer',
77
+ 'who.party.more',
78
+ 'who.party.count',
79
+ ];
53
80
  /** The tints the avatars take, in order. */
54
81
  const AVATAR_TINTS = ['bg-secondary/30', 'bg-primary/10', 'bg-accent'];
55
82
  const HEADING_ID = 'booking-who-heading';
@@ -77,7 +104,7 @@ function choicePressed(people, choice) {
77
104
  return (people.length === wanted.length &&
78
105
  people.every((person, index) => person.key === wanted[index]?.key));
79
106
  }
80
- export function WhoScreen({ labels, format, people, family, guestChoices, addedChildren = [], maxPeople, selfKey, isGuestSeat = noGuestSeats, onChoose, onAddChild, loginRow, currentYear, classNames, components, }) {
107
+ export function WhoScreen({ labels, format, people, family, guestChoices, addedChildren = [], maxPeople, selfKey, isGuestSeat = noGuestSeats, onChoose, onAddChild, guestParty, loginRow, currentYear, classNames, components, }) {
81
108
  const PersonCard = components?.PersonCard ?? DefaultPersonCard;
82
109
  const heading = (_jsx("h2", { id: HEADING_ID, tabIndex: -1, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: labels['who.heading'] }));
83
110
  const sheet = (disabled, saveNotes) => (_jsx(AddChildSheet, { labels: labels, format: format, saveNotes: saveNotes, disabled: disabled, currentYear: currentYear, onSave: onAddChild }));
@@ -85,8 +112,9 @@ export function WhoScreen({ labels, format, people, family, guestChoices, addedC
85
112
  className: classNames?.card,
86
113
  selectedClassName: classNames?.cardSelected,
87
114
  };
115
+ const partyReady = guestParty !== undefined && PARTY_REQUIRED_LABEL_KEYS.every((key) => labels[key] !== undefined);
88
116
  if (family === null) {
89
- return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [heading, loginRow, _jsxs("div", { className: "space-y-4", children: [_jsx(GuestChips, { choices: guestChoices, people: people, labels: labels, classNames: classNames, onChoose: onChoose }), addedChildren.length > 0 && (_jsx("ul", { "aria-label": labelText(labels['who.guest.addedList']), className: "space-y-2", children: addedChildren.map((entry, index) => (_jsx("li", { children: _jsx(PersonCard, { name: entry.person.name ?? '', line: entry.line, index: index, selected: true, disabled: false, onToggle: () => onChoose(people.filter((other) => other.key !== entry.person.key), false), ...cardClasses }) }, entry.person.key))) })), sheet(addedChildren.length >= maxPeople, false)] })] }));
117
+ return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [heading, loginRow, _jsxs("div", { className: "space-y-4", children: [partyReady && guestParty ? (_jsx(GuestParty, { party: guestParty, people: people, maxPeople: maxPeople, labels: labels, classNames: classNames, onChoose: onChoose })) : (_jsx(GuestChips, { choices: guestChoices, people: people, labels: labels, classNames: classNames, onChoose: onChoose })), addedChildren.length > 0 && (_jsx("ul", { "aria-label": labelText(labels['who.guest.addedList']), className: "space-y-2", children: addedChildren.map((entry, index) => (_jsx("li", { children: _jsx(PersonCard, { name: entry.person.name ?? '', line: entry.line, index: index, selected: true, disabled: false, onToggle: () => onChoose(people.filter((other) => other.key !== entry.person.key), false), ...cardClasses }) }, entry.person.key))) })), sheet(partyReady ? people.length >= maxPeople : addedChildren.length >= maxPeople, false)] })] }));
90
118
  }
91
119
  // Defensively only what this picker draws: the caller clears guest seats
92
120
  // when the family appears, and a seat nobody can see must never count
@@ -110,6 +138,55 @@ export function WhoScreen({ labels, format, people, family, guestChoices, addedC
110
138
  return (_jsx("li", { children: _jsx(PersonCard, { name: entry.person.name ?? '', line: entry.line, index: index, selected: selected, disabled: !selected && full, describedBy: !selected && full ? LIMIT_ID : undefined, onToggle: () => toggle(entry.person), ...cardClasses }) }, entry.person.key));
111
139
  }), _jsx("li", { children: sheet(full, true) })] }), _jsxs(CheckRow, { checked: selfChecked, disabled: !selfChecked && full, describedBy: !selfChecked && full ? LIMIT_ID : undefined, onToggle: () => toggle(self), className: cn('h-14 justify-between px-5 text-sm', classNames?.card), selectedClassName: classNames?.cardSelected, children: [_jsx("span", { className: "font-medium", children: labels['who.family.self'] }), _jsx(CheckCircle, { checked: selfChecked })] }), _jsx("p", { id: LIMIT_ID, "aria-live": "polite", className: slotClass(classNames, 'limit', 'min-h-5 text-sm text-muted-foreground'), children: full ? renderLabel(labels['who.family.limit'], { max: maxPeople }) : '' })] })] }));
112
140
  }
141
+ /**
142
+ * A guest's party: how many children (a stepper) and whether the grown-up
143
+ * comes too (a check row), so «a child and me» is one booking without an
144
+ * account. Starts at one child, which is the common answer, so «next» is live
145
+ * from the first frame.
146
+ */
147
+ function GuestParty({ party, people, maxPeople, labels, classNames, onChoose, }) {
148
+ const adultOn = people.some((person) => person.key === party.adult.key);
149
+ // The grown-up's seat is never a child, flag or no flag.
150
+ const childSeats = people.filter((person) => !person.adult && person.key !== party.adult.key);
151
+ const children = childSeats.length;
152
+ const full = children + (adultOn ? 1 : 0) >= maxPeople;
153
+ // Named children (from the add-child sheet) keep their seats, first; the
154
+ // stepper adds and removes the unnamed ones after them.
155
+ const generatedKeys = new Set(Array.from({ length: maxPeople }, (_, index) => party.child(index + 1).key));
156
+ const named = childSeats.filter((person) => !generatedKeys.has(person.key));
157
+ // The stepper never takes a named child away: their own card does that.
158
+ const floor = Math.max(named.length, adultOn ? 0 : 1);
159
+ const answer = (count, withAdult) => {
160
+ const kept = named;
161
+ const used = new Set(kept.map((person) => person.key));
162
+ const fill = [];
163
+ for (let seat = 1; kept.length + fill.length < count && seat <= maxPeople; seat += 1) {
164
+ const next = party.child(seat);
165
+ if (!used.has(next.key))
166
+ fill.push(next);
167
+ }
168
+ onChoose([...kept, ...fill, ...(withAdult ? [party.adult] : [])], false);
169
+ };
170
+ // One child whenever the answer becomes empty (first frame or cleared
171
+ // later): an empty answer would leave «next» dead on the most common visit.
172
+ // Once per emptying, so a parent that ignores the answer is not flooded,
173
+ // and a restored answer is never replaced.
174
+ const wasEmpty = useRef(false);
175
+ useEffect(() => {
176
+ const empty = people.length === 0;
177
+ if (empty && !wasEmpty.current)
178
+ answer(1, false);
179
+ wasEmpty.current = empty;
180
+ });
181
+ const text = (key, values = {}) => {
182
+ const label = labels[key];
183
+ return label ? fillLabel(label, values) : '';
184
+ };
185
+ const stepButton = 'flex size-11 shrink-0 items-center justify-center rounded-full border border-border bg-background text-foreground transition-colors hover:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-40';
186
+ return (_jsxs("div", { className: "space-y-3", children: [_jsxs("div", { className: cn('flex items-center gap-4 rounded-lg border border-border bg-card px-5 py-4', classNames?.card), children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "font-semibold", children: text('who.party.children') }), labels['who.party.childrenNote'] && (_jsx("p", { className: "text-sm text-muted-foreground", children: renderLabel(labels['who.party.childrenNote']) }))] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("button", { type: "button", "aria-label": text('who.party.fewer'), disabled: children <= floor, onClick: () => answer(children - 1, adultOn), className: stepButton, children: _jsx(Minus, { "aria-hidden": "true", className: "size-4" }) }), _jsxs("span", { "aria-live": "polite", "aria-atomic": "true", className: "w-6 text-center text-lg font-bold tabular-nums", children: [_jsx("span", { "aria-hidden": "true", children: children }), _jsx("span", { className: "sr-only", children: text('who.party.count', { count: children }) })] }), _jsx("button", { type: "button", "aria-label": text('who.party.more'), "aria-describedby": full ? LIMIT_ID : undefined, disabled: full, onClick: () => answer(children + 1, adultOn), className: stepButton, children: _jsx(Plus, { "aria-hidden": "true", className: "size-4" }) })] })] }), _jsxs(CheckRow, { checked: adultOn,
187
+ // Never down to nobody: the last person in the booking stays.
188
+ disabled: (!adultOn && full) || (adultOn && children === 0), describedBy: !adultOn && full ? LIMIT_ID : undefined, onToggle: () => answer(children, !adultOn), className: cn('min-h-14 justify-between px-5 py-3 text-sm', classNames?.card), selectedClassName: classNames?.cardSelected, children: [_jsxs("span", { className: "flex min-w-0 flex-col text-left", children: [_jsx("span", { className: "font-medium", children: text('who.party.adult') }), labels['who.party.adultNote'] && (_jsx("span", { className: "text-muted-foreground", children: renderLabel(labels['who.party.adultNote']) }))] }), _jsx(CheckCircle, { checked: adultOn })] }), _jsx("p", { id: LIMIT_ID, "aria-live": "polite", className: slotClass(classNames, 'limit', 'min-h-5 text-sm text-muted-foreground'), children: full ? renderLabel(labels['who.family.limit'], { max: maxPeople }) : '' })] }));
189
+ }
113
190
  /**
114
191
  * The guest chips as a radio group. Roving focus: one chip is in the tab
115
192
  * order (the chosen one, else the first) and the arrows move between them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medalsocial/meda",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
4
4
  "description": "Shared Meda UI shell and runtime package.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {