@medalsocial/meda 3.6.1 → 3.8.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 +29 -2
- package/dist/booking/stylist-screen.js +62 -14
- package/dist/booking/summary-bar.d.ts +22 -4
- package/dist/booking/summary-bar.js +24 -5
- package/dist/booking/time-screen.d.ts +48 -4
- package/dist/booking/time-screen.js +222 -17
- 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" | "time.week.none" | "time.week.more" | "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;
|
|
@@ -59,6 +65,8 @@ export interface StylistCardProps {
|
|
|
59
65
|
selectedClassName?: string;
|
|
60
66
|
/** Merged onto the next-opening line (the `nextAvailable` slot). */
|
|
61
67
|
nextAvailableClassName?: string;
|
|
68
|
+
/** `StylistScreen`'s `row`: draw the phone's avatar tile at every width. */
|
|
69
|
+
row?: boolean;
|
|
62
70
|
}
|
|
63
71
|
export interface StylistScreenComponents {
|
|
64
72
|
StylistCard?: ComponentType<StylistCardProps>;
|
|
@@ -98,6 +106,25 @@ export interface StylistScreenProps {
|
|
|
98
106
|
/** A polite announcement once the list is here (e.g. the restored stylist is gone). */
|
|
99
107
|
notice?: string | null;
|
|
100
108
|
onPick: (resourceId: string | null) => void;
|
|
109
|
+
/**
|
|
110
|
+
* Draw «first available» as the faces of the first three qualified stylists
|
|
111
|
+
* with a photo (in the salon's order) instead of its badge: it means «any of
|
|
112
|
+
* these», so it shows them, and only ones who can do the whole basket.
|
|
113
|
+
* Falls back to the badge with fewer than two photos.
|
|
114
|
+
*/
|
|
115
|
+
firstAvailableFaces?: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* On a phone, fade the right edge of the sideways row so a cut-off card reads
|
|
118
|
+
* as «there is more», not as a layout bug.
|
|
119
|
+
*/
|
|
120
|
+
edgeFade?: boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Keep the phone's one row of avatars at every width instead of the column
|
|
123
|
+
* of cards from `md`, so a wide screen reaches the times without scrolling
|
|
124
|
+
* past every stylist. The chosen stylist's bio, which a tile has no room
|
|
125
|
+
* for, is shown under the row.
|
|
126
|
+
*/
|
|
127
|
+
row?: boolean;
|
|
101
128
|
/**
|
|
102
129
|
* Present only for a family. One child gets no picker: «one after the
|
|
103
130
|
* other» is not a choice when there is nobody to be after.
|
|
@@ -124,6 +151,6 @@ export interface StylistScreenProps {
|
|
|
124
151
|
classNames?: SlotClassNames<StylistScreenSlot>;
|
|
125
152
|
components?: StylistScreenComponents;
|
|
126
153
|
}
|
|
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;
|
|
154
|
+
export declare function StylistScreen({ labels, format, now, serviceIds, resources, selectedResourceId, nextAvailableTs, nextAvailableLoading, loading, skeletonCount, pendingName, notice, onPick, firstAvailableFaces, edgeFade, row, party, classNames, components, }: StylistScreenProps): import("react").JSX.Element;
|
|
128
155
|
/** The default stylist option: avatar (photo or badge), name(s), bio, next opening. */
|
|
129
|
-
export declare function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, labels, className, selectedClassName, nextAvailableClassName, }: StylistCardProps): import("react").JSX.Element;
|
|
156
|
+
export declare function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, labels, className, selectedClassName, nextAvailableClassName, row, }: 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, row = 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, row: row, Card: components?.StylistCard ?? DefaultStylistCard })), row && party?.mode !== 'parallel' && (_jsx(ChosenBio, { options: options, selectedResourceId: selectedResourceId })), _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, row, Card, }) {
|
|
127
143
|
const groupRef = useRef(null);
|
|
128
144
|
const optionRefs = useRef([]);
|
|
129
145
|
const selectedIndex = options.findIndex((option) => option.resourceId === selectedResourceId);
|
|
@@ -176,9 +192,13 @@ 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
|
+
cn('pr-10 [mask-image:linear-gradient(to_right,#000_calc(100%-2.5rem),transparent)]', !row && 'md:[mask-image:none]'), !row && '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
|
-
}, 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)))] }));
|
|
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, row: row }, option.resourceId ?? 'first-available'))), Array.from({ length: skeletons }, (_, index) => (_jsx(SkeletonCard, { row: row }, index)))] }));
|
|
182
202
|
}
|
|
183
203
|
/** The restored stylist's stand-in: a real, checked radio of the final size,
|
|
184
204
|
* with its next-opening row pulsing like every card still waiting for one. */
|
|
@@ -208,23 +228,51 @@ function ModeCard({ selected, onClick, title, subtitle, classNames, }) {
|
|
|
208
228
|
: 'border-border bg-card hover:border-primary', classNames?.modeCard, selected && classNames?.modeCardSelected), children: [_jsx("span", { className: "font-medium", children: title }), _jsx("span", { className: "text-sm font-normal text-muted-foreground tabular-nums", children: subtitle })] }));
|
|
209
229
|
}
|
|
210
230
|
/** The default stylist option: avatar (photo or badge), name(s), bio, next opening. */
|
|
211
|
-
export function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, labels, className, selectedClassName, nextAvailableClassName, }) {
|
|
231
|
+
export function DefaultStylistCard({ ref, option, selected, tabIndex, onClick, labels, className, selectedClassName, nextAvailableClassName, row = false, }) {
|
|
212
232
|
const id = useId();
|
|
233
|
+
// From `md` the tile becomes a card, unless the screen keeps the row.
|
|
234
|
+
const md = (classes) => (row ? '' : classes);
|
|
213
235
|
const nameId = `${id}-name`;
|
|
214
236
|
const nextId = `${id}-next`;
|
|
215
237
|
const bioId = `${id}-bio`;
|
|
216
|
-
const { name, subtitle, badge, photoUrl, availability, availabilityLoading, reserveAvailability, } = option;
|
|
238
|
+
const { name, subtitle, badge, photoUrl, faces, availability, availabilityLoading, reserveAvailability, } = option;
|
|
217
239
|
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
240
|
// Ring and weight alongside the tint: the selected option has to be
|
|
219
241
|
// obvious on a monochrome screen too. On a phone the ring is on the
|
|
220
242
|
// avatar; from `md` it is on the card.
|
|
221
|
-
className: cn('group flex w-24 shrink-0 snap-start flex-col items-center gap-1 rounded-lg px-1 pt-1 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', 'md:w-full md:shrink md:flex-row md:gap-4 md:border md:px-5 md:py-4 md:text-left', OPTION_HEIGHT, selected
|
|
222
|
-
? 'font-semibold md:border-primary md:bg-primary/10 md:ring-2 md:ring-primary'
|
|
223
|
-
: '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
|
-
? '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:
|
|
243
|
+
className: cn('group flex w-24 shrink-0 snap-start flex-col items-center gap-1 rounded-lg px-1 pt-1 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', md('md:w-full md:shrink md:flex-row md:gap-4 md:border md:px-5 md:py-4 md:text-left'), row ? 'h-32' : OPTION_HEIGHT, selected
|
|
244
|
+
? cn('font-semibold', md('md:border-primary md:bg-primary/10 md:ring-2 md:ring-primary'))
|
|
245
|
+
: md('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('md:size-12'), selected
|
|
246
|
+
? cn('ring-2 ring-primary ring-offset-2 ring-offset-background', md('md:ring-0 md:ring-offset-0'))
|
|
247
|
+
: cn('ring-1 ring-border', md('md:ring-0'))), children: _jsx(StylistAvatarContent, { photoUrl: photoUrl, faces: faces, badge: badge, row: row }) }), _jsxs("span", { className: cn('flex w-full min-w-0 flex-col', md('md:w-auto')), children: [_jsx("span", { "aria-hidden": "true", className: cn('h-5 truncate text-sm font-medium', md('md:hidden')), children: option.shortName }), _jsx("span", { id: nameId, className: cn('hidden truncate font-medium', md('md:block')), children: name }), subtitle && (_jsx("span", { id: bioId, className: cn('hidden truncate text-sm text-muted-foreground', md('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('md:line-clamp-1 md:h-5 md:text-sm md:leading-5'), nextAvailableClassName), children: [_jsx("span", { className: cn('sr-only', md('md:not-sr-only')), children: labels['stylist.nextAvailable'] }), ' ', availability] })) : (reserveAvailability && (_jsx("span", { "data-testid": "next-available-row", "aria-hidden": "true", className: cn('flex h-8 justify-center pt-1', md('md:block md:h-5 md:py-1')), children: availabilityLoading && (_jsx("span", { className: cn('block h-3 w-16 animate-pulse rounded bg-muted', md('md:w-36')) })) })))] })] }));
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* What fills the avatar circle: the stylist's photo, a small group of faces
|
|
251
|
+
* for «first available» (`firstAvailableFaces`), or the badge.
|
|
252
|
+
*/
|
|
253
|
+
function StylistAvatarContent({ photoUrl, faces, badge, row, }) {
|
|
254
|
+
if (photoUrl) {
|
|
255
|
+
return (_jsx("img", { src: photoUrl, alt: "", className: "size-full object-cover", width: 56, height: 56, loading: "lazy", decoding: "async" }));
|
|
256
|
+
}
|
|
257
|
+
if (faces && faces.length >= 2) {
|
|
258
|
+
// A small group, for «any of these»: the faces of who it can be.
|
|
259
|
+
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', !row && 'md:size-[26px]', FACE_POSITIONS[index]) }, src))) }));
|
|
260
|
+
}
|
|
261
|
+
return badge;
|
|
226
262
|
}
|
|
227
263
|
/** A stylist option before there is a stylist: the same box, the same circle, at both widths. */
|
|
228
|
-
function SkeletonCard() {
|
|
229
|
-
|
|
264
|
+
function SkeletonCard({ row }) {
|
|
265
|
+
const md = (classes) => (row ? '' : classes);
|
|
266
|
+
return (_jsxs("div", { "aria-hidden": "true", "data-testid": "stylist-skeleton", className: cn('flex w-24 shrink-0 flex-col items-center gap-1 px-1 pt-1', md('md:w-full md:flex-row md:gap-4 md:rounded-lg md:border md:border-border md:bg-card md:px-5 md:py-4'), row ? 'h-32' : OPTION_HEIGHT), children: [_jsx("span", { className: cn('size-14 shrink-0 animate-pulse rounded-full bg-muted', md('md:size-12')) }), _jsxs("span", { className: cn('flex w-full min-w-0 flex-col items-center gap-2', md('md:w-auto md:flex-1 md:items-start')), children: [_jsx("span", { className: cn('h-4 w-14 animate-pulse rounded bg-muted', md('md:w-28')) }), _jsx("span", { className: cn('h-3 w-16 animate-pulse rounded bg-muted', md('md:w-40')) })] })] }));
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Under a `row`: the chosen stylist's bio, which a tile has no room for —
|
|
270
|
+
* «tar oftest de minste», read where the choice is made. Nothing for «first
|
|
271
|
+
* available», or a stylist without one.
|
|
272
|
+
*/
|
|
273
|
+
function ChosenBio({ options, selectedResourceId, }) {
|
|
274
|
+
const chosen = options.find((option) => option.resourceId === selectedResourceId);
|
|
275
|
+
if (!chosen || chosen.resourceId === null || !chosen.subtitle)
|
|
276
|
+
return null;
|
|
277
|
+
return (_jsxs("p", { "data-testid": "stylist-bio", className: "-mt-3 text-sm text-muted-foreground", children: [_jsx("span", { className: "font-medium text-foreground", children: chosen.name }), " \u00B7 ", chosen.subtitle] }));
|
|
230
278
|
}
|
|
@@ -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,14 @@ 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, `dayFullness` and the `adaptive` week
|
|
32
|
+
* grid. Placeholders: `time.soonest.pick` `{day}` `{time}`;
|
|
33
|
+
* `time.soonest.with` `{stylist}`; `time.dayChip.free` `{count}`;
|
|
34
|
+
* `time.week.more` `{count}`.
|
|
35
|
+
*/
|
|
36
|
+
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", "time.week.none", "time.week.more"];
|
|
37
|
+
export type TimeScreenLabels = Record<(typeof TIME_SCREEN_LABEL_KEYS)[number], BookingLabel> & Partial<Record<(typeof TIME_SCREEN_FINDER_LABEL_KEYS)[number], BookingLabel>>;
|
|
31
38
|
/**
|
|
32
39
|
* `classNames` slots:
|
|
33
40
|
* - `root` the section · `heading` the h2
|
|
@@ -39,7 +46,7 @@ export type TimeScreenLabels = Record<(typeof TIME_SCREEN_LABEL_KEYS)[number], B
|
|
|
39
46
|
* - `parallel` the «both at once» offer · `empty` the nothing-free card
|
|
40
47
|
* - `nextFree` the «next free time» link-button · `shortNotice` the short-notice line
|
|
41
48
|
*/
|
|
42
|
-
export type TimeScreenSlot = 'root' | 'heading' | 'dayStrip' | 'dayChip' | 'dayChipSelected' | 'month' | 'monthCell' | 'monthCellSelected' | 'surcharge' | 'daypart' | 'daypartHeading' | 'chip' | 'chipNearest' | 'chipCurrent' | 'parallel' | 'empty' | 'nextFree' | 'shortNotice';
|
|
49
|
+
export type TimeScreenSlot = 'root' | 'heading' | 'dayStrip' | 'dayChip' | 'dayChipSelected' | 'month' | 'monthCell' | 'monthCellSelected' | 'surcharge' | 'daypart' | 'daypartHeading' | 'chip' | 'chipNearest' | 'chipCurrent' | 'parallel' | 'empty' | 'nextFree' | 'shortNotice' | 'soonest';
|
|
43
50
|
export interface DayChipProps {
|
|
44
51
|
/** An instant inside the day. */
|
|
45
52
|
dayTs: number;
|
|
@@ -49,9 +56,18 @@ export interface DayChipProps {
|
|
|
49
56
|
/** The resolved classes (base + selected + slot overrides). */
|
|
50
57
|
className: string;
|
|
51
58
|
onSelect: () => void;
|
|
59
|
+
/**
|
|
60
|
+
* With `dayFullness`: how many starts the day has free (0 = full). Absent
|
|
61
|
+
* otherwise, and the chip is the plain one-line chip.
|
|
62
|
+
*/
|
|
63
|
+
free?: number;
|
|
64
|
+
/** With `free`: what the marks say, read out (e.g. «4 free», «full»). */
|
|
65
|
+
freeLabel?: string;
|
|
52
66
|
}
|
|
67
|
+
/** How full a day looks, as filled marks out of three: 1–3 free, 4–7, 8 and more. */
|
|
68
|
+
export declare function dayFreeMarks(free: number): 0 | 1 | 2 | 3;
|
|
53
69
|
/** 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;
|
|
70
|
+
export declare function DefaultDayChip({ label, selected, className, onSelect, free, freeLabel, }: DayChipProps): import("react").JSX.Element;
|
|
55
71
|
export interface TimeChipProps {
|
|
56
72
|
startTs: number;
|
|
57
73
|
/** «14:30», a party's «15:00 → 16:00», or the current hour's «15:00 · Current time». */
|
|
@@ -146,8 +162,36 @@ export interface TimeScreenProps {
|
|
|
146
162
|
onPick: (slot: BookingSlotDto) => void;
|
|
147
163
|
/** A family. Present, its slots replace `slots` entirely. */
|
|
148
164
|
party?: TimeScreenParty;
|
|
165
|
+
/**
|
|
166
|
+
* A «free soon» row above the days: the `count` (default 3) earliest starts
|
|
167
|
+
* across the whole window, one tap each, so «when is the first time?» is
|
|
168
|
+
* answered before any day is chosen. `resolveStylist` names who each would
|
|
169
|
+
* be with (the stylist `onPick` books). Not drawn for a family or a move.
|
|
170
|
+
* Needs the `time.soonest.*` labels.
|
|
171
|
+
*/
|
|
172
|
+
soonest?: {
|
|
173
|
+
count?: number;
|
|
174
|
+
resolveStylist?: (resourceId: string) => {
|
|
175
|
+
name: string;
|
|
176
|
+
photoUrl?: string | null;
|
|
177
|
+
} | null;
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* Draw each day chip with how many starts it has free (marks, or «full»),
|
|
181
|
+
* as a 44 px target. Needs the `time.dayChip.*` labels.
|
|
182
|
+
*/
|
|
183
|
+
dayFullness?: boolean;
|
|
149
184
|
/** «Now», for today / tomorrow and whether today is over. Defaults to `Date.now()`. */
|
|
150
185
|
now?: number;
|
|
186
|
+
/**
|
|
187
|
+
* `stack` (default): the days above the chosen day's times at every width.
|
|
188
|
+
* `adaptive`: the same on a phone, with the days, the month and the weekend
|
|
189
|
+
* note kept together; from `md` the days are a column beside the chosen
|
|
190
|
+
* day's times; from `lg` the whole week is a grid of times, seven columns,
|
|
191
|
+
* nothing to tap first. The grid needs the `time.week.*` labels and a strip
|
|
192
|
+
* of at most seven days (with `monthView`, the week on screen).
|
|
193
|
+
*/
|
|
194
|
+
layout?: 'stack' | 'adaptive';
|
|
151
195
|
classNames?: SlotClassNames<TimeScreenSlot>;
|
|
152
196
|
components?: TimeScreenComponents;
|
|
153
197
|
}
|
|
@@ -160,7 +204,7 @@ export interface TimeScreenProps {
|
|
|
160
204
|
* - `open` — open, with a start still ahead.
|
|
161
205
|
*/
|
|
162
206
|
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;
|
|
207
|
+
export declare function TimeScreen({ labels, format, dayparts, slots, days, openDays, stylistName, takenSlotTs, phone, weekendNote, currentSlotTs, monthView, onPick, party, soonest, dayFullness, now: nowProp, layout, classNames, components, }: TimeScreenProps): import("react").JSX.Element;
|
|
164
208
|
export interface TakenToastProps {
|
|
165
209
|
labels: Pick<TimeScreenLabels, 'time.taken.message' | 'time.taken.close'>;
|
|
166
210
|
/** The instant that was just lost; each new one is a fresh toast. */
|
|
@@ -1,7 +1,7 @@
|
|
|
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
|
-
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
5
5
|
import { cn } from '../lib/utils.js';
|
|
6
6
|
import { renderLabel } from './internal/label-parts.js';
|
|
7
7
|
import { BookingButton, bookingButtonClass } from './internal/ui.js';
|
|
@@ -64,9 +64,63 @@ 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, `dayFullness` and the `adaptive` week
|
|
83
|
+
* grid. Placeholders: `time.soonest.pick` `{day}` `{time}`;
|
|
84
|
+
* `time.soonest.with` `{stylist}`; `time.dayChip.free` `{count}`;
|
|
85
|
+
* `time.week.more` `{count}`.
|
|
86
|
+
*/
|
|
87
|
+
export const TIME_SCREEN_FINDER_LABEL_KEYS = [
|
|
88
|
+
/** Heading of the soonest-times row, e.g. «Free soon». */
|
|
89
|
+
'time.soonest.heading',
|
|
90
|
+
/** Accessible name of a soonest card: `{day}` `{time}`, e.g. «{day} at {time}». */
|
|
91
|
+
'time.soonest.pick',
|
|
92
|
+
/** Appended to it when the card names a stylist: `{stylist}`, e.g. «with {stylist}». */
|
|
93
|
+
'time.soonest.with',
|
|
94
|
+
/** Screen-reader text of a day chip with openings: `{count}`, e.g. «{count} free». */
|
|
95
|
+
'time.dayChip.free',
|
|
96
|
+
/** The same for exactly one opening, where the language needs it (nb «1 ledig»). Falls back to `free`. */
|
|
97
|
+
'time.dayChip.freeOne',
|
|
98
|
+
/** A day chip with none, shown and read, e.g. «full». */
|
|
99
|
+
'time.dayChip.full',
|
|
100
|
+
/** A week-grid day with nothing free, e.g. «Nothing free». */
|
|
101
|
+
'time.week.none',
|
|
102
|
+
/** A week-grid day's button for the rest of its times: `{count}`, e.g. «{count} more». */
|
|
103
|
+
'time.week.more',
|
|
104
|
+
];
|
|
105
|
+
/** How full a day looks, as filled marks out of three: 1–3 free, 4–7, 8 and more. */
|
|
106
|
+
export function dayFreeMarks(free) {
|
|
107
|
+
if (free <= 0)
|
|
108
|
+
return 0;
|
|
109
|
+
if (free <= 3)
|
|
110
|
+
return 1;
|
|
111
|
+
if (free <= 7)
|
|
112
|
+
return 2;
|
|
113
|
+
return 3;
|
|
114
|
+
}
|
|
67
115
|
/** One chip of the day strip: an `aria-pressed` outline button. */
|
|
68
|
-
export function DefaultDayChip({ label, selected, className, onSelect }) {
|
|
69
|
-
|
|
116
|
+
export function DefaultDayChip({ label, selected, className, onSelect, free, freeLabel, }) {
|
|
117
|
+
if (free === undefined) {
|
|
118
|
+
return (_jsx(BookingButton, { variant: "outline", size: "sm", "aria-pressed": selected, onClick: onSelect, className: className, children: label }));
|
|
119
|
+
}
|
|
120
|
+
const marks = dayFreeMarks(free);
|
|
121
|
+
// Two lines and a 44 px target: the day, and under it how much room it has,
|
|
122
|
+
// so a full day is seen before it is tapped rather than after.
|
|
123
|
+
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
124
|
}
|
|
71
125
|
/** One time chip. */
|
|
72
126
|
export function DefaultTimeChip({ label, nearest, nearestLabel, current, className, onPick, }) {
|
|
@@ -209,7 +263,7 @@ function monthGrid(clock, ts) {
|
|
|
209
263
|
// ---------------------------------------------------------------------------
|
|
210
264
|
// TimeScreen
|
|
211
265
|
// ---------------------------------------------------------------------------
|
|
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, }) {
|
|
266
|
+
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, layout = 'stack', classNames, components, }) {
|
|
213
267
|
const now = nowProp ?? Date.now();
|
|
214
268
|
const { clock } = format;
|
|
215
269
|
const DayChip = components?.DayChip ?? DefaultDayChip;
|
|
@@ -221,6 +275,22 @@ export function TimeScreen({ labels, format, dayparts, slots, days, openDays = n
|
|
|
221
275
|
const offered = currentSlotTs !== null && !bookable.some((slot) => slot.startTs === currentSlotTs)
|
|
222
276
|
? [...bookable, { startTs: currentSlotTs, resourceId: null }]
|
|
223
277
|
: bookable;
|
|
278
|
+
// Free starts per day, for the fullness marks (the current-hour marker is not free).
|
|
279
|
+
const freeByDay = new Map();
|
|
280
|
+
if (dayFullness) {
|
|
281
|
+
for (const slot of bookable) {
|
|
282
|
+
if (slot.startTs === currentSlotTs)
|
|
283
|
+
continue;
|
|
284
|
+
const key = clock.dayKey(slot.startTs);
|
|
285
|
+
freeByDay.set(key, (freeByDay.get(key) ?? 0) + 1);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const soonestSlots = soonest && !party && currentSlotTs === null
|
|
289
|
+
? [...bookable]
|
|
290
|
+
.filter((slot) => slot.startTs >= now)
|
|
291
|
+
.sort((a, b) => a.startTs - b.startTs)
|
|
292
|
+
.slice(0, soonest.count ?? 3)
|
|
293
|
+
: [];
|
|
224
294
|
const partyByStart = new Map(party?.slots.map((slot) => [slot.startTs, slot]) ?? []);
|
|
225
295
|
const offeredDays = buildDayStrip(clock, offered, days, takenSlotTs, currentSlotTs, openDays, now);
|
|
226
296
|
// Read back through the offered days rather than held as a day of its own,
|
|
@@ -273,10 +343,152 @@ export function TimeScreen({ labels, format, dayparts, slots, days, openDays = n
|
|
|
273
343
|
const alternative = party !== undefined && party.mode === 'sequential'
|
|
274
344
|
? (party.alternativeFor?.(selectedDay) ?? null)
|
|
275
345
|
: null;
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
346
|
+
const adaptive = layout === 'adaptive';
|
|
347
|
+
// The week grid shows the strip whole, so it needs one week of it, and its words.
|
|
348
|
+
const weekNone = labels['time.week.none'];
|
|
349
|
+
const weekMore = labels['time.week.more'];
|
|
350
|
+
const week = adaptive &&
|
|
351
|
+
strip.length > 1 &&
|
|
352
|
+
strip.length <= DAY_STRIP_LIMIT &&
|
|
353
|
+
weekNone !== undefined &&
|
|
354
|
+
weekMore !== undefined
|
|
355
|
+
? { none: weekNone, more: weekMore }
|
|
356
|
+
: null;
|
|
357
|
+
const TimeChip = components?.TimeChip ?? DefaultTimeChip;
|
|
358
|
+
const dayStrip = strip.length > 1 && (_jsxs("fieldset", { className: slotClass(classNames, 'dayStrip', cn('flex flex-wrap gap-2', adaptive && 'md:flex-col md:flex-nowrap')), children: [_jsx("legend", { className: "sr-only", children: labels['time.dayStrip.legend'] }), strip.map((day) => {
|
|
359
|
+
const selected = clock.dayKey(day) === selectedKey;
|
|
360
|
+
return (_jsx(DayChip, { dayTs: day, label: clock.dayChip(day, now), selected: selected, onSelect: () => setTappedDayKey(clock.dayKey(day)), ...(dayFullness
|
|
361
|
+
? dayFreeProps(labels, freeByDay.get(clock.dayKey(day)) ?? 0,
|
|
362
|
+
// «Full» only when it is true of the business: an open
|
|
363
|
+
// day, asked for everyone — not hours that could not be
|
|
364
|
+
// read, nor a day one named stylist does not work, nor a
|
|
365
|
+
// family's day (the other mode may still seat them).
|
|
366
|
+
!party &&
|
|
367
|
+
stylistName === null &&
|
|
368
|
+
dayStanding(clock, day, openDays, now) === 'open')
|
|
369
|
+
: {}), className: cn('rounded-full',
|
|
370
|
+
// A column of days: full-width rows, not pills.
|
|
371
|
+
adaptive && 'md:w-full md:rounded-lg', selected ? 'border-primary bg-primary/10 font-bold ring-2 ring-primary' : '', classNames?.dayChip, selected ? classNames?.dayChipSelected : undefined) }, clock.dayKey(day)));
|
|
372
|
+
})] }));
|
|
373
|
+
const month = 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 }));
|
|
374
|
+
const surchargeRow = 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 }));
|
|
375
|
+
const dayTimes = (_jsxs(_Fragment, { children: [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: TimeChip })), 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 }))] }));
|
|
376
|
+
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 })), adaptive ? (_jsxs(_Fragment, { children: [month, _jsxs("div", { "data-testid": "time-columns", className: cn('space-y-6 md:grid md:grid-cols-[minmax(0,13rem)_minmax(0,1fr)] md:items-start md:gap-8 md:space-y-0', week && 'lg:hidden'), children: [_jsxs("div", { className: "space-y-2", children: [dayStrip, surchargeRow] }), _jsx("div", { className: "min-w-0 space-y-6", children: dayTimes })] }), week && (_jsx(WeekGrid, { labels: labels, words: week, format: format, days: strip, offered: offered, currentSlotTs: currentSlotTs, nearest: nearest, party: party, partyByStart: partyByStart, onPick: onPick, freeByDay: dayFullness ? freeByDay : null, openDays: openDays, stylistName: stylistName, weekendNote: weekendNote, phone: phone, now: now, classNames: classNames, TimeChip: TimeChip, empty: _jsx(NothingFreeCard, { labels: labels, format: format, day: selectedDay, now: now, phone: phone, standing: dayStanding(clock, selectedDay, openDays, now), stylistName: stylistName, className: classNames?.empty }), next: elsewhere &&
|
|
377
|
+
!strip.some((day) => clock.dayKey(day) === clock.dayKey(elsewhere.startTs)) && (_jsx(BookingButton, { variant: "link", onClick: () => setTappedDayKey(clock.dayKey(elsewhere.startTs)), className: classNames?.nextFree, children: labels['time.nextFree'] })), className: "hidden lg:block" }))] })) : (_jsxs(_Fragment, { children: [dayStrip, month, surchargeRow, dayTimes] }))] }));
|
|
378
|
+
}
|
|
379
|
+
/** How many times a week-grid day shows before «{count} more». */
|
|
380
|
+
const WEEK_CAP = 8;
|
|
381
|
+
/**
|
|
382
|
+
* `layout="adaptive"` from `lg`: the week as seven columns of times, so a
|
|
383
|
+
* wide screen books in one tap. Each day heads its column with how full it is;
|
|
384
|
+
* a long day folds after `WEEK_CAP`; an empty one says so, or offers a
|
|
385
|
+
* family's simultaneous time. The chips are the strip's own `TimeChip`s.
|
|
386
|
+
*/
|
|
387
|
+
function WeekGrid({ labels, words, format, days, offered, currentSlotTs, nearest, party, partyByStart, onPick, freeByDay, openDays, stylistName, weekendNote, phone, now, classNames, TimeChip, empty, next, className, }) {
|
|
388
|
+
const { clock } = format;
|
|
389
|
+
const id = useId();
|
|
390
|
+
const [unfolded, setUnfolded] = useState(null);
|
|
391
|
+
// Unfolding unmounts the button that held focus: hand it to the first time
|
|
392
|
+
// it revealed, so a keyboard user carries on where they were.
|
|
393
|
+
const lists = useRef(new Map());
|
|
394
|
+
useEffect(() => {
|
|
395
|
+
if (unfolded === null)
|
|
396
|
+
return;
|
|
397
|
+
lists.current.get(unfolded)?.querySelectorAll('button')[WEEK_CAP]?.focus();
|
|
398
|
+
}, [unfolded]);
|
|
399
|
+
let todayOpen = false;
|
|
400
|
+
let anyBookable = false;
|
|
401
|
+
const columns = days.map((day, column) => {
|
|
402
|
+
const key = clock.dayKey(day);
|
|
403
|
+
const daySlots = offered
|
|
404
|
+
.filter((slot) => clock.dayKey(slot.startTs) === key)
|
|
405
|
+
.sort((a, b) => a.startTs - b.startTs);
|
|
406
|
+
const bookable = daySlots.filter((slot) => slot.startTs !== currentSlotTs).length;
|
|
407
|
+
if (bookable > 0 && clock.daysBetween(now, day) === 0)
|
|
408
|
+
todayOpen = true;
|
|
409
|
+
if (bookable > 0)
|
|
410
|
+
anyBookable = true;
|
|
411
|
+
// Each weekend day says its own surcharge: the price is the basket priced
|
|
412
|
+
// on that date, and Saturday's need not be Sunday's.
|
|
413
|
+
const note = clock.isWeekend(day) ? (weekendNote?.(day) ?? null) : null;
|
|
414
|
+
const shown = unfolded === key ? daySlots : daySlots.slice(0, WEEK_CAP);
|
|
415
|
+
const more = daySlots.length - shown.length;
|
|
416
|
+
const headId = `${id}-day-${column}`;
|
|
417
|
+
const alternative = bookable === 0 && party?.mode === 'sequential' ? (party.alternativeFor?.(day) ?? null) : null;
|
|
418
|
+
if (alternative)
|
|
419
|
+
anyBookable = true;
|
|
420
|
+
const free = freeByDay === null
|
|
421
|
+
? null
|
|
422
|
+
: dayFreeProps(labels, freeByDay.get(key) ?? 0, !party && stylistName === null && dayStanding(clock, day, openDays, now) === 'open').freeLabel;
|
|
423
|
+
return (_jsxs("li", { className: "min-w-0 space-y-2", children: [_jsxs("p", { id: headId, className: "flex flex-col items-center gap-0.5 rounded-lg bg-muted/50 px-1 py-2 text-center", children: [_jsx("span", { className: "max-w-full truncate text-sm font-semibold", children: clock.dayChip(day, now) }), free ? (_jsx("span", { className: "max-w-full truncate text-xs text-muted-foreground", children: free })) : null] }), note !== null && (_jsx("p", { "data-testid": "week-surcharge", className: slotClass(classNames, 'surcharge', 'px-1 text-center text-xs leading-tight text-muted-foreground'), children: weekendSentence(labels, format, note) })), shown.length > 0 && (_jsx("ul", { ref: (node) => {
|
|
424
|
+
lists.current.set(key, node);
|
|
425
|
+
}, "aria-labelledby": headId, className: "flex flex-col gap-1.5", children: shown.map((slot) => {
|
|
426
|
+
const partySlot = partyByStart.get(slot.startTs);
|
|
427
|
+
const current = slot.startTs === currentSlotTs;
|
|
428
|
+
const isNearest = !current && nearest.has(slot.startTs);
|
|
429
|
+
return (_jsx("li", { children: _jsx(TimeChip, { startTs: slot.startTs, label: chipLabel(labels, clock, slot, current, partySlot, party), nearest: isNearest, nearestLabel: labelText(labels['time.nearest']), current: current, onPick: () => (partySlot && party ? party.onPick(partySlot) : onPick(slot)), className: cn('h-auto min-h-11 w-full min-w-0 whitespace-normal px-1 tabular-nums', isNearest ? 'border-primary ring-2 ring-primary' : '', classNames?.chip, isNearest ? classNames?.chipNearest : undefined, current ? classNames?.chipCurrent : undefined) }) }, slot.startTs));
|
|
430
|
+
}) })), more > 0 && (_jsx(BookingButton, { variant: "link", size: "sm", onClick: () => setUnfolded(key), className: "w-full", children: renderLabel(words.more, { count: more }) })), bookable === 0 &&
|
|
431
|
+
(alternative && party ? (_jsx(BookingButton, { size: "sm", onClick: () => party.onPick(alternative), className: "h-auto min-h-11 w-full whitespace-normal tabular-nums", children: renderLabel(labels['time.parallel.accept'], {
|
|
432
|
+
time: clock.formatTime(alternative.startTs),
|
|
433
|
+
}) })) : (_jsx("p", { className: "px-1 text-center text-xs text-muted-foreground", children: renderLabel(words.none) })))] }, key));
|
|
434
|
+
});
|
|
435
|
+
return (_jsxs("div", { "data-testid": "time-week", className: cn('space-y-4', className), children: [_jsx("ol", { className: "grid grid-cols-7 gap-2", children: columns }), !anyBookable && empty, next, todayOpen && (_jsx(ShortNoticeLine, { labels: labels, format: format, phone: phone, className: classNames?.shortNotice }))] }));
|
|
436
|
+
}
|
|
437
|
+
/** A time chip's words: the hour, the held hour marked, or a family's whole visit. */
|
|
438
|
+
function chipLabel(labels, clock, slot, current, partySlot, party) {
|
|
439
|
+
const time = clock.formatTime(slot.startTs);
|
|
440
|
+
if (current)
|
|
441
|
+
return fillLabel(labels['time.current'], { time });
|
|
442
|
+
if (partySlot && party)
|
|
443
|
+
return partyChipLabel(labels, clock, partySlot, party);
|
|
444
|
+
return time;
|
|
445
|
+
}
|
|
446
|
+
/** The weekend note as one plain sentence, for a card that books without the notice on screen. */
|
|
447
|
+
function weekendSentenceText(labels, format, note) {
|
|
448
|
+
const price = format.price(note.priceOre);
|
|
449
|
+
return note.pct === null
|
|
450
|
+
? fillLabel(labels['time.weekend.mixed'], { price })
|
|
451
|
+
: fillLabel(labels['time.weekend.pct'], { pct: note.pct, price });
|
|
452
|
+
}
|
|
453
|
+
/** A day chip's `free` and `freeLabel`. */
|
|
454
|
+
function dayFreeProps(labels, free, canClaimFull) {
|
|
455
|
+
let label;
|
|
456
|
+
if (free === 0)
|
|
457
|
+
label = canClaimFull ? labels['time.dayChip.full'] : undefined;
|
|
458
|
+
else if (free === 1)
|
|
459
|
+
label = labels['time.dayChip.freeOne'] ?? labels['time.dayChip.free'];
|
|
460
|
+
else
|
|
461
|
+
label = labels['time.dayChip.free'];
|
|
462
|
+
return { free, freeLabel: label ? fillLabel(label, { count: free }) : '' };
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* «Free soon»: the earliest starts across the window as cards (the day, the
|
|
466
|
+
* time, and who it would be with), each booking exactly what `onPick` books
|
|
467
|
+
* for that start.
|
|
468
|
+
*/
|
|
469
|
+
function SoonestRow({ labels, format, slots, now, resolveStylist, weekendNote, onPick, className, }) {
|
|
470
|
+
const { clock } = format;
|
|
471
|
+
const heading = labels['time.soonest.heading'];
|
|
472
|
+
const pick = labels['time.soonest.pick'];
|
|
473
|
+
const withWho = labels['time.soonest.with'];
|
|
474
|
+
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) => {
|
|
475
|
+
const stylist = slot.resourceId !== null && resolveStylist ? resolveStylist(slot.resourceId) : null;
|
|
476
|
+
const day = clock.dayChip(slot.startTs, now);
|
|
477
|
+
const time = clock.formatTime(slot.startTs);
|
|
478
|
+
const name = stylist ? format.stylistName(stylist.name) : null;
|
|
479
|
+
// A weekend card says its surcharge itself: the notice under the
|
|
480
|
+
// days only speaks for the day on screen, and a card books without it.
|
|
481
|
+
const note = clock.isWeekend(slot.startTs) ? (weekendNote?.(slot.startTs) ?? null) : null;
|
|
482
|
+
const surcharge = note ? weekendSentenceText(labels, format, note) : null;
|
|
483
|
+
const ariaLabel = [
|
|
484
|
+
pick ? fillLabel(pick, { day, time }) : `${day} ${time}`,
|
|
485
|
+
name && withWho ? fillLabel(withWho, { stylist: name }) : null,
|
|
486
|
+
surcharge,
|
|
487
|
+
]
|
|
488
|
+
.filter(Boolean)
|
|
489
|
+
.join(' ');
|
|
490
|
+
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));
|
|
491
|
+
}) })] }));
|
|
280
492
|
}
|
|
281
493
|
function TimeHeading({ labels, className, }) {
|
|
282
494
|
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'] }));
|
|
@@ -382,14 +594,7 @@ function DaypartSections({ labels, clock, dayparts, daySlots, nearest, currentSl
|
|
|
382
594
|
const partySlot = partyByStart.get(slot.startTs);
|
|
383
595
|
const current = slot.startTs === currentSlotTs;
|
|
384
596
|
const isNearest = !current && nearest.has(slot.startTs);
|
|
385
|
-
|
|
386
|
-
if (current) {
|
|
387
|
-
label = fillLabel(labels['time.current'], { time: label });
|
|
388
|
-
}
|
|
389
|
-
else if (partySlot && party) {
|
|
390
|
-
label = partyChipLabel(labels, clock, partySlot, party);
|
|
391
|
-
}
|
|
392
|
-
return (_jsx(TimeChip, { startTs: slot.startTs, label: label, nearest: isNearest, nearestLabel: labelText(labels['time.nearest']), current: current, onPick: () => (partySlot && party ? party.onPick(partySlot) : onPick(slot)), className: cn('min-w-20 tabular-nums', isNearest ? 'border-primary ring-2 ring-primary' : '', classNames?.chip, isNearest ? classNames?.chipNearest : undefined, current ? classNames?.chipCurrent : undefined) }, slot.startTs));
|
|
597
|
+
return (_jsx(TimeChip, { startTs: slot.startTs, label: chipLabel(labels, clock, slot, current, partySlot, party), nearest: isNearest, nearestLabel: labelText(labels['time.nearest']), current: current, onPick: () => (partySlot && party ? party.onPick(partySlot) : onPick(slot)), className: cn('min-w-20 tabular-nums', isNearest ? 'border-primary ring-2 ring-primary' : '', classNames?.chip, isNearest ? classNames?.chipNearest : undefined, current ? classNames?.chipCurrent : undefined) }, slot.startTs));
|
|
393
598
|
}) })] }, daypart.key));
|
|
394
599
|
});
|
|
395
600
|
}
|
|
@@ -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.
|