@medalsocial/meda 3.6.1 → 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.
- package/dist/booking/booking-recap.d.ts +60 -0
- package/dist/booking/booking-recap.js +70 -0
- package/dist/booking/details-screen.js +9 -2
- package/dist/booking/index.d.ts +1 -0
- package/dist/booking/index.js +1 -0
- package/dist/booking/label-keys.d.ts +6 -5
- package/dist/booking/label-keys.js +8 -3
- package/dist/booking/stylist-screen.d.ts +19 -1
- package/dist/booking/stylist-screen.js +40 -6
- package/dist/booking/summary-bar.d.ts +22 -4
- package/dist/booking/summary-bar.js +24 -5
- package/dist/booking/time-screen.d.ts +38 -4
- package/dist/booking/time-screen.js +127 -6
- package/dist/booking/who-screen.d.ts +24 -2
- package/dist/booking/who-screen.js +82 -5
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
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'], {
|
|
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
|
package/dist/booking/index.d.ts
CHANGED
package/dist/booking/index.js
CHANGED
|
@@ -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';
|
|
@@ -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,
|
|
@@ -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',
|
|
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:
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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)),
|
|
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
|
-
|
|
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.
|