@medalsocial/meda 3.4.0 → 3.6.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/confirmation.js +5 -4
- package/dist/booking/details-screen.d.ts +7 -2
- package/dist/booking/details-screen.js +11 -3
- package/dist/booking/internal/service-multi.d.ts +55 -0
- package/dist/booking/internal/service-multi.js +83 -0
- package/dist/booking/internal/visit.d.ts +13 -0
- package/dist/booking/internal/visit.js +10 -0
- package/dist/booking/label-keys.d.ts +12 -5
- package/dist/booking/label-keys.js +2 -1
- package/dist/booking/login-panel.d.ts +9 -5
- package/dist/booking/login-panel.js +26 -5
- package/dist/booking/login-sheet.d.ts +1 -1
- package/dist/booking/service-screen.d.ts +77 -3
- package/dist/booking/service-screen.js +129 -44
- package/dist/booking/time-screen.js +37 -14
- package/dist/booking/types.d.ts +14 -1
- package/dist/booking/who-screen.js +1 -1
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { joinLabelParts, renderLabel } from './internal/label-parts.js';
|
|
4
4
|
import { BookingButton } from './internal/ui.js';
|
|
5
|
+
import { visitName } from './internal/visit.js';
|
|
5
6
|
import { fillLabel, labelText } from './labels.js';
|
|
6
7
|
import { slotClass } from './slots.js';
|
|
7
8
|
/**
|
|
@@ -54,10 +55,10 @@ export function Confirmation({ lines: rawLines, startTs, totalOre, address = nul
|
|
|
54
55
|
const singleCard = first ? (_jsx("p", { className: slotClass(classNames, 'card', 'rounded-lg border border-border bg-card px-5 py-4 font-medium'), children: joinLabelParts([
|
|
55
56
|
first.item.bookedForName
|
|
56
57
|
? renderLabel(labels['confirmation.serviceFor'], {
|
|
57
|
-
service: first.item
|
|
58
|
+
service: visitName(first.item),
|
|
58
59
|
name: first.item.bookedForName,
|
|
59
60
|
})
|
|
60
|
-
: first.item
|
|
61
|
+
: visitName(first.item),
|
|
61
62
|
first.stylistName,
|
|
62
63
|
renderLabel(labels['confirmation.when'], {
|
|
63
64
|
day: clock.dayLabel(startTs, now),
|
|
@@ -82,7 +83,7 @@ function PartyCard({ lines, totalOre, now, format, labels, classNames, PartyLine
|
|
|
82
83
|
const text = fillLabel(labels['confirmation.party.line'], {
|
|
83
84
|
time,
|
|
84
85
|
name: line.item.bookedForName ?? '',
|
|
85
|
-
service: line.item
|
|
86
|
+
service: visitName(line.item),
|
|
86
87
|
stylist: line.stylistName
|
|
87
88
|
? fillLabel(labels['confirmation.party.stylist'], { name: line.stylistName })
|
|
88
89
|
: '',
|
|
@@ -95,7 +96,7 @@ function PartyCard({ lines, totalOre, now, format, labels, classNames, PartyLine
|
|
|
95
96
|
return (_jsx(PartyLine
|
|
96
97
|
// biome-ignore lint/suspicious/noArrayIndexKey: two lines can share a service; the index is the identity
|
|
97
98
|
, { line: line, text: text, manageLabel: labelText(labels['confirmation.manage']), manageAriaLabel: fillLabel(labels['confirmation.manageFor'], {
|
|
98
|
-
who: line.item.bookedForName ?? line.item
|
|
99
|
+
who: line.item.bookedForName ?? visitName(line.item),
|
|
99
100
|
time,
|
|
100
101
|
}), linkClassName: slotClass(classNames, 'link', 'text-sm font-semibold text-primary underline underline-offset-4') }, index));
|
|
101
102
|
}) }), _jsx("p", { className: "font-semibold", children: renderLabel(labels['confirmation.party.total'], { total: format.price(totalOre) }) })] }));
|
|
@@ -13,7 +13,7 @@ import type { BookingFamilyMember, WizardAction, WizardError, WizardState } from
|
|
|
13
13
|
* booking — the keystrokes behind the per-child fields, which field has focus,
|
|
14
14
|
* whether submit has been pressed, and a fallback submission nonce.
|
|
15
15
|
*/
|
|
16
|
-
export declare const DETAILS_SCREEN_LABEL_KEYS: readonly ["details.heading", "details.phone.label", "details.phone.prefix", "details.phone.error", "details.name.label", "details.known.label", "details.known.for", "details.known.self", "details.known.adult", "details.known.child", "details.child.name", "details.child.year", "details.child.yearPlaceholder", "details.email.label", "details.email.help", "details.email.error", "details.notes.label", "details.notes.placeholder", "details.terms.text", "details.terms.link", "details.marketing.text", "details.error.maxParty", "details.error.slotTaken", "details.error.conflict", "details.error.invalidInput", "details.error.unconfigured", "details.error.upstreamError", "details.error.inProgress", "details.call.link", "details.call.none", "details.submit"];
|
|
16
|
+
export declare const DETAILS_SCREEN_LABEL_KEYS: readonly ["details.heading", "details.phone.label", "details.phone.prefix", "details.phone.error", "details.name.label", "details.known.label", "details.known.for", "details.known.self", "details.known.adult", "details.known.child", "details.child.name", "details.child.year", "details.child.yearPlaceholder", "details.email.label", "details.email.help", "details.email.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"];
|
|
17
17
|
export type DetailsScreenLabelKey = (typeof DETAILS_SCREEN_LABEL_KEYS)[number];
|
|
18
18
|
export type DetailsScreenLabels = Record<DetailsScreenLabelKey, BookingLabel>;
|
|
19
19
|
/**
|
|
@@ -24,7 +24,10 @@ export type DetailsScreenLabels = Record<DetailsScreenLabelKey, BookingLabel>;
|
|
|
24
24
|
export declare const DETAILS_ERROR_LABEL_KEYS: Record<WizardError, DetailsScreenLabelKey>;
|
|
25
25
|
/** One line of the submission. Optional fields are ABSENT, never blank. */
|
|
26
26
|
export interface BookingSubmissionItem {
|
|
27
|
+
/** The person's first service. */
|
|
27
28
|
serviceId: string;
|
|
29
|
+
/** The services after the first, in order, in the same visit. ABSENT for a one-service visit. */
|
|
30
|
+
extraServiceIds?: string[];
|
|
28
31
|
resourceId?: string;
|
|
29
32
|
startTs: number;
|
|
30
33
|
bookedForName?: string;
|
|
@@ -107,6 +110,8 @@ export interface DetailsScreenProps {
|
|
|
107
110
|
guardianPhone?: string | null;
|
|
108
111
|
/** When set, a link to the full terms is drawn after the terms sentence. */
|
|
109
112
|
termsHref?: string | null;
|
|
113
|
+
/** The e-mail is the address the guardian logged in with: shown, not editable. */
|
|
114
|
+
emailReadOnly?: boolean;
|
|
110
115
|
/** Draw the (optional, unticked) marketing opt-in. */
|
|
111
116
|
marketingConsent?: boolean;
|
|
112
117
|
format: BookingFormat;
|
|
@@ -116,4 +121,4 @@ export interface DetailsScreenProps {
|
|
|
116
121
|
}
|
|
117
122
|
/** Blank is fine (the field is optional). Anything else has to be reachable. */
|
|
118
123
|
export declare function looksLikeEmail(value: string): boolean;
|
|
119
|
-
export declare function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phoneLooksValid, normalisePhone, submitting, phone, submissionNonce: nonceProp, family, guardianPhone, termsHref, marketingConsent, format, labels, classNames, components, }: DetailsScreenProps): import("react").JSX.Element;
|
|
124
|
+
export declare function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phoneLooksValid, normalisePhone, submitting, phone, submissionNonce: nonceProp, family, guardianPhone, termsHref, emailReadOnly, marketingConsent, format, labels, classNames, components, }: DetailsScreenProps): import("react").JSX.Element;
|
|
@@ -7,6 +7,7 @@ import { Input } from '../primitives/input.js';
|
|
|
7
7
|
import { Textarea } from '../primitives/textarea.js';
|
|
8
8
|
import { renderLabel } from './internal/label-parts.js';
|
|
9
9
|
import { BOOKING_CHECKBOX_CLASS, BOOKING_INPUT_CLASS, BOOKING_TEXTAREA_CLASS, BookingButton, } from './internal/ui.js';
|
|
10
|
+
import { visitName } from './internal/visit.js';
|
|
10
11
|
import { fillLabel, labelText } from './labels.js';
|
|
11
12
|
import { slotClass } from './slots.js';
|
|
12
13
|
/**
|
|
@@ -41,6 +42,7 @@ export const DETAILS_SCREEN_LABEL_KEYS = [
|
|
|
41
42
|
'details.child.yearPlaceholder',
|
|
42
43
|
'details.email.label',
|
|
43
44
|
'details.email.help',
|
|
45
|
+
'details.email.lockedHelp',
|
|
44
46
|
'details.email.error',
|
|
45
47
|
'details.notes.label',
|
|
46
48
|
'details.notes.placeholder',
|
|
@@ -170,7 +172,7 @@ function isKnownChair(state, index) {
|
|
|
170
172
|
// ---------------------------------------------------------------------------
|
|
171
173
|
// Screen
|
|
172
174
|
// ---------------------------------------------------------------------------
|
|
173
|
-
export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phoneLooksValid, normalisePhone, submitting = false, phone = null, submissionNonce: nonceProp, family, guardianPhone = null, termsHref = null, marketingConsent = false, format, labels, classNames, components, }) {
|
|
175
|
+
export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phoneLooksValid, normalisePhone, submitting = false, phone = null, submissionNonce: nonceProp, family, guardianPhone = null, termsHref = null, emailReadOnly = false, marketingConsent = false, format, labels, classNames, components, }) {
|
|
174
176
|
const ids = useId();
|
|
175
177
|
const field = (name) => `${ids}-${name}`;
|
|
176
178
|
const phoneRef = useRef(null);
|
|
@@ -196,6 +198,9 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
|
|
|
196
198
|
const phoneTyped = state.contact.phone.trim().length > 0;
|
|
197
199
|
const phoneValid = phoneLooksValid(state.contact.phone);
|
|
198
200
|
const emailValid = looksLikeEmail(state.contact.email);
|
|
201
|
+
// Only lock an address that is present and well-formed; a blank or malformed
|
|
202
|
+
// one stays editable so the parent is never stuck with an unfixable field.
|
|
203
|
+
const emailLocked = emailReadOnly && state.contact.email.trim().length > 0 && emailValid;
|
|
199
204
|
const showPhoneError = !phoneValid && (attempted || (phoneTyped && focused !== 'phone'));
|
|
200
205
|
const showEmailError = !emailValid && (attempted || focused !== 'email');
|
|
201
206
|
const showTermsError = attempted && !state.consentTerms;
|
|
@@ -243,6 +248,9 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
|
|
|
243
248
|
const resourceId = line?.resourceId ?? null;
|
|
244
249
|
return {
|
|
245
250
|
serviceId: item.service.id,
|
|
251
|
+
...(item.extraServices && item.extraServices.length > 0
|
|
252
|
+
? { extraServiceIds: item.extraServices.map((service) => service.id) }
|
|
253
|
+
: {}),
|
|
246
254
|
...(resourceId === null ? {} : { resourceId }),
|
|
247
255
|
startTs: line?.startTs ?? startTs,
|
|
248
256
|
...present('bookedForName', isKnownChair(state, index) ? (item.bookedForName ?? '') : child.name),
|
|
@@ -287,11 +295,11 @@ export function DetailsScreen({ state, onChange, onSubmit, lines, totalOre, phon
|
|
|
287
295
|
}
|
|
288
296
|
const inputClass = (...extra) => slotClass(classNames, 'input', BOOKING_INPUT_CLASS, ...extra);
|
|
289
297
|
const fieldClass = slotClass(classNames, 'field');
|
|
290
|
-
return (_jsxs("section", { "aria-labelledby": "booking-details-heading", className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h2", { id: "booking-details-heading", tabIndex: -1, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: labels['details.heading'] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('phone'), children: labels['details.phone.label'] }), _jsxs("div", { className: "flex items-stretch", children: [phonePrefix && (_jsx("span", { id: field('phone-prefix'), className: "flex h-12 items-center rounded-l-[4px] border border-r-0 border-input bg-muted px-4 text-base text-muted-foreground", children: phonePrefix })), _jsx(Input, { id: field('phone'), ref: phoneRef, type: "tel", inputMode: "numeric", autoComplete: "tel-national", className: inputClass(phonePrefix ? 'rounded-l-none' : ''), value: state.contact.phone, "aria-describedby": phonePrefix ? field('phone-prefix') : undefined, "aria-invalid": showPhoneError || undefined, onFocus: () => setFocused('phone'), onBlur: () => setFocused(null), onChange: (event) => setContact('phone', event.target.value) })] }), showPhoneError && _jsx(Field.Error, { children: labels['details.phone.error'] })] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('name'), children: labels['details.name.label'] }), _jsx(Input, { id: field('name'), autoComplete: "name", className: inputClass(), value: state.contact.name, onChange: (event) => setContact('name', event.target.value) })] }), state.items.some((_, index) => isKnownChair(state, index)) && (_jsx("ul", { "aria-label": labelText(labels['details.known.label']), className: "space-y-1 text-sm", children: state.items.map((item, index) => isKnownChair(state, index) ? (_jsxs("li", { children: [_jsx("span", { className: "font-semibold", children: item
|
|
298
|
+
return (_jsxs("section", { "aria-labelledby": "booking-details-heading", className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx("h2", { id: "booking-details-heading", tabIndex: -1, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: labels['details.heading'] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('phone'), children: labels['details.phone.label'] }), _jsxs("div", { className: "flex items-stretch", children: [phonePrefix && (_jsx("span", { id: field('phone-prefix'), className: "flex h-12 items-center rounded-l-[4px] border border-r-0 border-input bg-muted px-4 text-base text-muted-foreground", children: phonePrefix })), _jsx(Input, { id: field('phone'), ref: phoneRef, type: "tel", inputMode: "numeric", autoComplete: "tel-national", className: inputClass(phonePrefix ? 'rounded-l-none' : ''), value: state.contact.phone, "aria-describedby": phonePrefix ? field('phone-prefix') : undefined, "aria-invalid": showPhoneError || undefined, onFocus: () => setFocused('phone'), onBlur: () => setFocused(null), onChange: (event) => setContact('phone', event.target.value) })] }), showPhoneError && _jsx(Field.Error, { children: labels['details.phone.error'] })] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('name'), children: labels['details.name.label'] }), _jsx(Input, { id: field('name'), autoComplete: "name", className: inputClass(), value: state.contact.name, onChange: (event) => setContact('name', event.target.value) })] }), state.items.some((_, index) => isKnownChair(state, index)) && (_jsx("ul", { "aria-label": labelText(labels['details.known.label']), className: "space-y-1 text-sm", children: state.items.map((item, index) => isKnownChair(state, index) ? (_jsxs("li", { children: [_jsx("span", { className: "font-semibold", children: visitName(item) }), ' ', renderLabel(labels['details.known.for'], { who: knownWho(state, index, labels) })] }, index)) : null) })), state.items.map((item, index) =>
|
|
291
299
|
// A seat step 1 already named is not asked again: a second answer could disagree.
|
|
292
300
|
isKnownChair(state, index) ? null : (_jsx(ChildFields
|
|
293
301
|
// biome-ignore lint/suspicious/noArrayIndexKey: two lines can share a service; the index is the identity
|
|
294
|
-
, { legend: state.items.length > 1 ? item
|
|
302
|
+
, { legend: state.items.length > 1 ? visitName(item) : null, nameId: field(`child-name-${index}`), yearId: field(`child-year-${index}`), value: childAt(index), family: family, labels: labels, classNames: classNames, FamilyChip: FamilyChip, onChange: (patch, personId) => setChild(index, patch, personId) }, index))), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('email'), children: labels['details.email.label'] }), _jsx(Input, { id: field('email'), ref: emailRef, type: "email", autoComplete: "email", className: inputClass(), value: state.contact.email, readOnly: emailLocked || undefined, "aria-readonly": emailLocked || undefined, "aria-describedby": field('email-help'), "aria-invalid": showEmailError || undefined, onFocus: () => setFocused('email'), onBlur: () => setFocused(null), onChange: (event) => setContact('email', event.target.value) }), _jsx(Field.Description, { id: field('email-help'), children: emailLocked ? labels['details.email.lockedHelp'] : labels['details.email.help'] }), showEmailError && _jsx(Field.Error, { children: labels['details.email.error'] })] }), _jsxs(Field, { className: fieldClass, children: [_jsx(Field.Label, { htmlFor: field('notes'), children: labels['details.notes.label'] }), _jsx(Textarea, { id: field('notes'), rows: 3, className: slotClass(classNames, 'input', BOOKING_TEXTAREA_CLASS), placeholder: labelText(labels['details.notes.placeholder']), value: state.notes, onChange: (event) => onChange({ type: 'setNotes', value: event.target.value }) })] }), _jsxs(Field, { orientation: "horizontal", className: slotClass(classNames, 'consent'), children: [_jsx(Checkbox, { id: field('terms'), ref: termsRef, className: BOOKING_CHECKBOX_CLASS, checked: state.consentTerms, "aria-invalid": showTermsError || undefined, onCheckedChange: (accepted) => onChange({ type: 'setConsent', which: 'terms', accepted }) }), _jsx(Field.Label, { htmlFor: field('terms'), className: "cursor-pointer font-normal", children: labels['details.terms.text'] }), termsHref && (_jsx("a", { href: termsHref, className: "col-start-2 text-sm font-semibold text-primary underline underline-offset-4", children: labels['details.terms.link'] }))] }), marketingConsent && (_jsxs(Field, { orientation: "horizontal", className: slotClass(classNames, 'consent'), children: [_jsx(Checkbox, { id: field('marketing'), className: BOOKING_CHECKBOX_CLASS, checked: state.consentMarketing, onCheckedChange: (accepted) => onChange({ type: 'setConsent', which: 'marketing', accepted }) }), _jsx(Field.Label, { htmlFor: field('marketing'), className: "cursor-pointer font-normal", children: labels['details.marketing.text'] })] })), state.error && (_jsx(SubmitFailure, { error: state.error, phone: phone, format: format, labels: labels, className: slotClass(classNames, 'error', 'rounded-lg border border-destructive/40 bg-destructive/10 px-5 py-3 text-sm') })), _jsx(BookingButton, { size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: submitting, "aria-busy": submitting || undefined, onClick: handleSubmit, children: renderLabel(labels['details.submit'], { price }) })] }));
|
|
295
303
|
}
|
|
296
304
|
/**
|
|
297
305
|
* The name and birth year for one line. Wrapped in a fieldset only when there
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { BookingFormat } from '../format.js';
|
|
3
|
+
import type { ServiceMultiLabels, ServicePartyPerson, ServiceScreenSlot, ServiceSelection } from '../service-screen.js';
|
|
4
|
+
import { type SlotClassNames } from '../slots.js';
|
|
5
|
+
/**
|
|
6
|
+
* The service step's multi-select parts (`ServiceScreenProps.selection`):
|
|
7
|
+
* the per-person tabs, the sticky total bar and the checkbox card. Internal —
|
|
8
|
+
* `ServiceScreen` composes them; the package does not export them.
|
|
9
|
+
*/
|
|
10
|
+
export declare const serviceTabId: (index: number) => string;
|
|
11
|
+
export declare const serviceTabPanelId: (index: number) => string;
|
|
12
|
+
/**
|
|
13
|
+
* One tab per person on the multi-select step, a tick on everyone who has
|
|
14
|
+
* something. The WAI-ARIA tabs pattern: one tab stop, arrows move between
|
|
15
|
+
* people, Home / End jump to the ends; moving selects (one short panel, no
|
|
16
|
+
* cost to showing it).
|
|
17
|
+
*/
|
|
18
|
+
export declare function PersonTabs({ people, active, done, onSelect, labels, labelledBy, describedBy, classNames, }: {
|
|
19
|
+
people: ReadonlyArray<ServicePartyPerson>;
|
|
20
|
+
active: number;
|
|
21
|
+
done: (index: number) => boolean;
|
|
22
|
+
onSelect: (index: number) => void;
|
|
23
|
+
labels: ServiceMultiLabels;
|
|
24
|
+
labelledBy: string;
|
|
25
|
+
describedBy: string;
|
|
26
|
+
classNames: SlotClassNames<ServiceScreenSlot> | undefined;
|
|
27
|
+
}): import("react").JSX.Element;
|
|
28
|
+
/**
|
|
29
|
+
* The multi-select step's sticky bar: the running total, what is still
|
|
30
|
+
* missing, and «Next». The same bar as `SummaryBar`, so the step reads as one
|
|
31
|
+
* flow; a caller that also renders `SummaryBar` hides it on this step.
|
|
32
|
+
*
|
|
33
|
+
* «Next» is never disabled. A disabled button explains nothing and is skipped
|
|
34
|
+
* by a screen reader's tab order; this one stays, the line says what is
|
|
35
|
+
* missing, and the caller decides what pressing it does.
|
|
36
|
+
*/
|
|
37
|
+
export declare function TotalBar({ selection, format, classNames, }: {
|
|
38
|
+
selection: ServiceSelection;
|
|
39
|
+
format: BookingFormat;
|
|
40
|
+
classNames: SlotClassNames<ServiceScreenSlot> | undefined;
|
|
41
|
+
}): import("react").JSX.Element;
|
|
42
|
+
/**
|
|
43
|
+
* A service card on the multi-select step: a real checkbox inside its label.
|
|
44
|
+
* Space (and Enter) toggles it, the label's whole box is the target, and a
|
|
45
|
+
* screen reader hears «checkbox, checked».
|
|
46
|
+
*/
|
|
47
|
+
export declare function MultiServiceCard({ name, selected, onPick, duration, price, className, selectedClassName, }: {
|
|
48
|
+
name: string;
|
|
49
|
+
selected: boolean;
|
|
50
|
+
onPick: () => void;
|
|
51
|
+
duration: ReactNode;
|
|
52
|
+
price: ReactNode;
|
|
53
|
+
className?: string;
|
|
54
|
+
selectedClassName?: string;
|
|
55
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Check } from 'lucide-react';
|
|
4
|
+
import { cn } from '../../lib/utils.js';
|
|
5
|
+
import { LiveStatus } from '../live-status.js';
|
|
6
|
+
import { slotClass } from '../slots.js';
|
|
7
|
+
import { renderLabel } from './label-parts.js';
|
|
8
|
+
import { BookingButton } from './ui.js';
|
|
9
|
+
/**
|
|
10
|
+
* The service step's multi-select parts (`ServiceScreenProps.selection`):
|
|
11
|
+
* the per-person tabs, the sticky total bar and the checkbox card. Internal —
|
|
12
|
+
* `ServiceScreen` composes them; the package does not export them.
|
|
13
|
+
*/
|
|
14
|
+
export const serviceTabId = (index) => `booking-service-tab-${index}`;
|
|
15
|
+
export const serviceTabPanelId = (index) => `booking-service-panel-${index}`;
|
|
16
|
+
/**
|
|
17
|
+
* One tab per person on the multi-select step, a tick on everyone who has
|
|
18
|
+
* something. The WAI-ARIA tabs pattern: one tab stop, arrows move between
|
|
19
|
+
* people, Home / End jump to the ends; moving selects (one short panel, no
|
|
20
|
+
* cost to showing it).
|
|
21
|
+
*/
|
|
22
|
+
export function PersonTabs({ people, active, done, onSelect, labels, labelledBy, describedBy, classNames, }) {
|
|
23
|
+
function onKeyDown(event) {
|
|
24
|
+
const last = people.length - 1;
|
|
25
|
+
const moves = {
|
|
26
|
+
ArrowRight: active === last ? 0 : active + 1,
|
|
27
|
+
ArrowLeft: active === 0 ? last : active - 1,
|
|
28
|
+
Home: 0,
|
|
29
|
+
End: last,
|
|
30
|
+
};
|
|
31
|
+
const next = Object.hasOwn(moves, event.key) ? moves[event.key] : null;
|
|
32
|
+
if (next === null)
|
|
33
|
+
return;
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
onSelect(next);
|
|
36
|
+
document.getElementById(serviceTabId(next))?.focus();
|
|
37
|
+
}
|
|
38
|
+
return (_jsx("div", { role: "tablist", "aria-labelledby": labelledBy, "aria-describedby": describedBy, onKeyDown: onKeyDown, className: "-mx-1 flex gap-2 overflow-x-auto px-1 py-1", children: people.map((person, index) => {
|
|
39
|
+
const selected = index === active;
|
|
40
|
+
const finished = done(index);
|
|
41
|
+
return (_jsxs("button", { type: "button", role: "tab", id: serviceTabId(index), "aria-selected": selected, "aria-controls": selected ? serviceTabPanelId(index) : undefined, tabIndex: selected ? 0 : -1, onClick: () => onSelect(index),
|
|
42
|
+
// Weight and ring carry the open tab alongside the colour, as the
|
|
43
|
+
// category pills do. Focus is an outline, so it stacks on the
|
|
44
|
+
// selection ring instead of replacing it with a fainter one.
|
|
45
|
+
className: cn('inline-flex h-11 shrink-0 items-center gap-1.5 rounded-full border-[1.5px] px-4 text-sm whitespace-nowrap transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring', selected
|
|
46
|
+
? 'border-primary bg-primary/10 font-bold text-foreground ring-2 ring-primary'
|
|
47
|
+
: 'border-primary/35 font-normal text-primary hover:border-primary hover:bg-primary/5', classNames?.tab, selected && classNames?.tabSelected), children: [finished && _jsx(Check, { "aria-hidden": "true", className: "size-4 shrink-0" }), person.label, finished && (_jsxs("span", { className: "sr-only", children: [', ', renderLabel(labels['service.tabDone'])] }))] }, person.key));
|
|
48
|
+
}) }));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The multi-select step's sticky bar: the running total, what is still
|
|
52
|
+
* missing, and «Next». The same bar as `SummaryBar`, so the step reads as one
|
|
53
|
+
* flow; a caller that also renders `SummaryBar` hides it on this step.
|
|
54
|
+
*
|
|
55
|
+
* «Next» is never disabled. A disabled button explains nothing and is skipped
|
|
56
|
+
* by a screen reader's tab order; this one stays, the line says what is
|
|
57
|
+
* missing, and the caller decides what pressing it does.
|
|
58
|
+
*/
|
|
59
|
+
export function TotalBar({ selection, format, classNames, }) {
|
|
60
|
+
const { total, canContinue, labels } = selection;
|
|
61
|
+
return (_jsxs("div", { className: slotClass(classNames, 'bar', 'sticky bottom-0 z-40 border-t border-border bg-background/95 backdrop-blur-md'), children: [_jsx(LiveStatus, { text: selection.notice ?? null, className: "px-5 text-sm font-medium text-foreground [&:not(:empty)]:pt-3" }), _jsxs("div", { className: "flex h-16 items-center justify-between gap-4 px-5", children: [_jsxs("div", { "aria-live": "polite", "aria-atomic": "true", className: "min-w-0 text-sm tabular-nums", children: [total !== null && (_jsx("p", { className: "truncate font-semibold", children: renderLabel(labels['service.total'], {
|
|
62
|
+
minutes: total.minutes,
|
|
63
|
+
price: format.price(total.priceOre),
|
|
64
|
+
}) })), !canContinue && (_jsx("p", { className: "truncate text-muted-foreground", children: renderLabel(labels['service.chooseFirst']) }))] }), _jsx(BookingButton, { size: "lg", onClick: selection.onContinue, children: renderLabel(labels['service.continue']) })] })] }));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A service card on the multi-select step: a real checkbox inside its label.
|
|
68
|
+
* Space (and Enter) toggles it, the label's whole box is the target, and a
|
|
69
|
+
* screen reader hears «checkbox, checked».
|
|
70
|
+
*/
|
|
71
|
+
export function MultiServiceCard({ name, selected, onPick, duration, price, className, selectedClassName, }) {
|
|
72
|
+
return (_jsxs("label", { className: cn('flex min-h-14 w-full cursor-pointer items-center gap-4 rounded-lg border px-5 py-4 text-left transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary', selected ? 'border-primary bg-primary/5' : 'border-border bg-card hover:border-primary', className, selected && selectedClassName), children: [_jsx("input", { type: "checkbox", className: "sr-only", checked: selected, onChange: onPick,
|
|
73
|
+
// Enter as well as Space: on a phone-sized list the card reads as a
|
|
74
|
+
// button, and a keyboard visitor presses it like one.
|
|
75
|
+
onKeyDown: (event) => {
|
|
76
|
+
if (event.key !== 'Enter')
|
|
77
|
+
return;
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
onPick();
|
|
80
|
+
} }), _jsx("span", { "aria-hidden": "true", className: cn('flex size-6 shrink-0 items-center justify-center rounded-[4px] border-[1.5px] transition-colors',
|
|
81
|
+
// The empty box needs 3:1 against the card to be seen at all.
|
|
82
|
+
selected ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground'), children: selected && _jsx(Check, { className: "size-4" }) }), _jsxs("span", { className: "flex min-w-0 flex-1 items-baseline justify-between gap-4", children: [_jsx("span", { className: "font-medium", children: name }), _jsxs("span", { className: "flex items-baseline gap-4 text-right", children: [_jsx("span", { className: "text-sm text-muted-foreground tabular-nums", children: duration }), price] })] })] }));
|
|
83
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { WizardService } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* One person's visit: their first service and, when they booked more, the
|
|
4
|
+
* ones after it — done back to back, with one stylist.
|
|
5
|
+
*/
|
|
6
|
+
export interface VisitLike {
|
|
7
|
+
service: WizardService;
|
|
8
|
+
extraServices?: ReadonlyArray<WizardService>;
|
|
9
|
+
}
|
|
10
|
+
/** Every service in the visit, in the order they are done. */
|
|
11
|
+
export declare function visitServices(item: VisitLike): WizardService[];
|
|
12
|
+
/** The visit's services by name: «Klipp + Vask». */
|
|
13
|
+
export declare function visitName(item: VisitLike): string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Every service in the visit, in the order they are done. */
|
|
2
|
+
export function visitServices(item) {
|
|
3
|
+
return [item.service, ...(item.extraServices ?? [])];
|
|
4
|
+
}
|
|
5
|
+
/** The visit's services by name: «Klipp + Vask». */
|
|
6
|
+
export function visitName(item) {
|
|
7
|
+
return visitServices(item)
|
|
8
|
+
.map((service) => service.name)
|
|
9
|
+
.join(' + ');
|
|
10
|
+
}
|
|
@@ -16,7 +16,7 @@ import { REBOOK_CARDS_LABEL_KEYS } from './portal/rebook-cards.js';
|
|
|
16
16
|
import { UPCOMING_BOOKINGS_LABEL_KEYS } from './portal/upcoming-bookings.js';
|
|
17
17
|
import { VIPPS_LINK_ROW_LABEL_KEYS } from './portal/vipps-link-row.js';
|
|
18
18
|
import { VISIT_HISTORY_LABEL_KEYS } from './portal/visit-history.js';
|
|
19
|
-
import { SERVICE_SCREEN_LABEL_KEYS } from './service-screen.js';
|
|
19
|
+
import { SERVICE_SCREEN_LABEL_KEYS, SERVICE_SCREEN_MULTI_LABEL_KEYS } from './service-screen.js';
|
|
20
20
|
import { STYLIST_SCREEN_LABEL_KEYS } from './stylist-screen.js';
|
|
21
21
|
import { SUMMARY_BAR_LABEL_KEYS } from './summary-bar.js';
|
|
22
22
|
import { TIME_SCREEN_LABEL_KEYS } from './time-screen.js';
|
|
@@ -29,7 +29,14 @@ import { WHO_SCREEN_LABEL_KEYS } from './who-screen.js';
|
|
|
29
29
|
* (Screens that embed another screen already include its keys, e.g. the
|
|
30
30
|
* login sheet carries the login panel's, OTP slots' and Vipps button's.)
|
|
31
31
|
*/
|
|
32
|
-
export declare const BOOKING_LABEL_KEYS: readonly ("addChild.trigger" | "addChild.title" | "addChild.description.saved" | "addChild.description.local" | "addChild.name" | "addChild.birthYear" | "addChild.birthYearPlaceholder" | "addChild.birthMonth" | "addChild.birthMonthUnknown" | "addChild.notes" | "addChild.missing" | "addChild.submit" | "addChild.close" | "bookingSkeleton.opening" | "confirmation.heading" | "confirmation.serviceFor" | "confirmation.when" | "confirmation.party.heading" | "confirmation.party.line" | "confirmation.party.stylist" | "confirmation.party.total" | "confirmation.calendar" | "confirmation.manage" | "confirmation.manageFor" | "confirmation.portal" | "confirmation.startOver" | "details.heading" | "details.phone.label" | "details.phone.prefix" | "details.phone.error" | "details.name.label" | "details.known.label" | "details.known.for" | "details.known.self" | "details.known.adult" | "details.known.child" | "details.child.name" | "details.child.year" | "details.child.yearPlaceholder" | "details.email.label" | "details.email.help" | "details.email.error" | "details.notes.label" | "details.notes.placeholder" | "details.terms.text" | "details.terms.link" | "details.marketing.text" | "details.error.maxParty" | "details.error.slotTaken" | "details.error.conflict" | "details.error.invalidInput" | "details.error.unconfigured" | "details.error.upstreamError" | "details.error.inProgress" | "details.call.link" | "details.call.none" | "details.submit" | "otp.label" | "vipps.button" | "vipps.unavailable" | "vipps.throttled" | "login.heading" | "login.codeHeading" | "login.or" | "login.emailIntro" | "login.emailLabel" | "login.sendCode" | "login.codeLabel" | "login.submitCode" | "login.codeHelp" | "login.codeHelpVipps" | "login.vippsSentTo" | "login.vippsSentToUnknown" | "login.cooldownWait" | "login.cooldownReady" | "login.resendIn" | "login.resend" | "login.switchEmail" | "login.useEmailInstead" | "login.notice.sent" | "login.notice.resent" | "login.notice.unreachable" | "login.notice.badEmail" | "login.notice.invalid" | "login.notice.throttled" | "login.notice.vippsInvalid" | "login.notice.vippsConflict" | "loginSheet.prompt" | "loginSheet.trigger" | "loginSheet.intro" | "loginSheet.vipps" | "loginSheet.close" | "time.heading" | "time.dayStrip.legend" | "time.nearest" | "time.current" | "time.nextFree" | "time.shortNotice" | "time.weekend.pct" | "time.weekend.mixed" | "time.month.show" | "time.month.hide" | "time.month.previous" | "time.month.next" | "time.month.heading" | "time.month.note" | "time.party.sequential" | "time.party.parallel" | "time.party.seat" | "time.party.seatSeparator" | "time.parallel.offer.two" | "time.parallel.offer.other" | "time.parallel.accept" | "time.empty.closed" | "time.empty.over" | "time.empty.full" | "time.empty.stylist" | "time.empty.unknown" | "time.empty.callLink" | "time.empty.callLinkAria" | "time.empty.callSuffix" | "time.empty.callPlain" | "time.loading" | "time.skeleton.dayPlaceholder" | "time.taken.message" | "time.taken.close" | "manage.heading" | "manage.serviceFallback" | "manage.card.named" | "manage.card.when" | "manage.card.whenWithStylist" | "manage.appointment.named" | "manage.appointment.namedSibilant" | "manage.appointment.unnamed" | "manage.ringUs" | "manage.ringUsOn" | "manage.callUs" | "manage.callUsOn" | "manage.error.windowPassed" | "manage.error.slotTaken" | "manage.error.notFound" | "manage.error.conflict" | "manage.error.invalidInput" | "manage.error.unavailable" | "manage.freeUntil" | "manage.remaining.in" | "manage.remaining.daysAndHours" | "manage.remaining.underMinute" | "manage.remaining.day.one" | "manage.remaining.day.other" | "manage.remaining.hour.one" | "manage.remaining.hour.other" | "manage.remaining.minute.one" | "manage.remaining.minute.other" | "manage.change" | "manage.cancel" | "manage.callToChange" | "manage.callToCancel" | "manage.blocked" | "manage.blocked.suffix" | "manage.blocked.plain" | "manage.calendar" | "manage.closed.cancelled" | "manage.closed.completed" | "manage.closed.noShow" | "manage.closed.pending" | "manage.closed.pendingCall" | "manage.closed.pendingCallPlain" | "manage.bookNew" | "manage.party.heading" | "manage.party.justThisOne" | "manage.party.unnamed" | "manage.party.note" | "manage.party.noteSuffix" | "manage.back" | "manage.reschedule.lead" | "manage.reschedule.leadWithStylist" | "manage.reschedule.failed" | "manage.reschedule.failedSuffix" | "manage.reschedule.loading" | "manage.move.question" | "manage.move.questionWithStylist" | "manage.move.surcharge" | "manage.move.confirm" | "manage.move.pickAnother" | "manage.cancel.question" | "manage.cancel.reasonLegend" | "manage.cancel.reason.illness" | "manage.cancel.reason.noLongerSuits" | "manage.cancel.reason.other" | "manage.cancel.confirm" | "manage.cancel.keep" | "manage.cancelled" | "manage.cancelled.findNew" | "manage.moved" | "manage.movedTo" | "manage.moved.view" | "manage.moved.noLink" | "manage.moved.noLinkSuffix" | "manage.portal" | "childCards.empty" | "childCards.neverVisited" | "childCards.lastVisit" | "childCards.lastVisitNoService" | "childCards.book" | "childCards.stylist" | "childCards.next" | "childCards.none" | "dataControls.heading" | "dataControls.export" | "dataControls.delete" | "dataControls.confirmHeading" | "dataControls.consequences" | "dataControls.confirmLabel" | "dataControls.confirmHelp" | "dataControls.wrongWord" | "dataControls.deleteForever" | "dataControls.cancel" | "dataControls.unreachable" | "dataControls.confirmWord" | "ageConfirm.heading" | "ageConfirm.lead" | "ageConfirm.legend" | "ageConfirm.birthYear" | "ageConfirm.birthMonth" | "ageConfirm.birthMonthUnknown" | "ageConfirm.confirm" | "ageConfirm.dismiss" | "familyEditor.heading" | "familyEditor.lead" | "familyEditor.empty" | "familyEditor.legend" | "familyEditor.add" | "familyEditor.name" | "familyEditor.birthYear" | "familyEditor.birthYearPlaceholder" | "familyEditor.birthMonth" | "familyEditor.birthMonthUnknown" | "familyEditor.stylist" | "familyEditor.stylistNone" | "familyEditor.stylistFormer" | "familyEditor.notes" | "familyEditor.notesPlaceholder" | "familyEditor.newRow" | "familyEditor.save" | "familyEditor.remove" | "familyEditor.removeNamed" | "familyEditor.removeRow" | "familyEditor.saved" | "familyEditor.savedNoDetails" | "familyEditor.partial" | "familyEditor.removed" | "familyEditor.unreachable" | "familyEditor.ageConfirmed" | "familyEditor.ageSaved" | "logout.button" | "logout.unreachable" | "portalShell.nav" | "portalShell.navBar" | "portalUnreachable.heading" | "portalUnreachable.body" | "portalUnreachable.retry" | "portalUnreachable.call" | "profileForm.heading" | "profileForm.firstName" | "profileForm.lastName" | "profileForm.phone" | "profileForm.email" | "profileForm.emailHelp" | "profileForm.save" | "profileForm.saved" | "profileForm.restored" | "profileForm.marketing" | "profileForm.unreachable" | "rebook.heading" | "rebook.empty" | "rebook.serviceFallback" | "rebook.anyStylist" | "rebook.cta" | "upcoming.heading" | "upcoming.empty" | "upcoming.book" | "upcoming.next" | "upcoming.today" | "upcoming.tomorrow" | "upcoming.inDays" | "upcoming.when" | "upcoming.serviceFallback" | "upcoming.anyStylist" | "upcoming.manage" | "upcoming.noManage" | "upcoming.noManageWithPhone" | "upcoming.call" | "vippsLink.heading" | "vippsLink.linked" | "vippsLink.pitch" | "vippsLink.button" | "vippsLink.success" | "vippsLink.conflict" | "vippsLink.failed" | "vippsLink.unavailable" | "vippsLink.throttled" | "history.heading" | "history.empty" | "history.serviceFallback" | "history.count.one" | "history.count.other" | "history.unknownAmount" | "service.heading" | "service.categoriesLegend" | "service.sameAsLast" | "service.chooseOther" | "service.duration" | "service.phoneOnly" | "service.phoneOnlyLinkLabel" | "service.ageDivider.named" | "service.ageDivider.unnamed" | "service.party.listLabel" | "service.party.adultAlone" | "service.party.nothingFits" | "service.party.remove" | "stylist.heading" | "stylist.firstAvailable" | "stylist.firstAvailableSubtitle" | "stylist.firstAvailableBadge" | "stylist.pendingName" | "stylist.nextAvailable" | "stylist.loading" | "stylist.party.legend" | "stylist.party.sequential" | "stylist.party.sequentialMinutes" | "stylist.party.parallel.two" | "stylist.party.parallel.three" | "stylist.party.parallel.other" | "stylist.party.parallelMinutes" | "stylist.party.parallelNote.two" | "stylist.party.parallelNote.three" | "stylist.party.parallelNote.other" | "summary.placeholder.who" | "summary.placeholder.service" | "summary.next" | "who.heading" | "who.guest.groupLabel" | "who.guest.children.one" | "who.guest.children.other" | "who.guest.adult" | "who.guest.addedList" | "who.family.legend" | "who.family.self" | "who.family.limit")[];
|
|
33
|
-
export type BookingLabelKey =
|
|
34
|
-
/**
|
|
35
|
-
|
|
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 type BookingLabelKey = BookingRequiredLabelKey | BookingOptionalLabelKey;
|
|
34
|
+
/**
|
|
35
|
+
* Keys added after a screen first shipped, for a feature a caller opts into.
|
|
36
|
+
* A pack may leave them out; a screen using the feature needs them.
|
|
37
|
+
*/
|
|
38
|
+
export type BookingOptionalLabelKey = (typeof SERVICE_SCREEN_MULTI_LABEL_KEYS)[number];
|
|
39
|
+
/** Keys every complete pack defines. */
|
|
40
|
+
export type BookingRequiredLabelKey = (typeof WHO_SCREEN_LABEL_KEYS)[number] | (typeof ADD_CHILD_SHEET_LABEL_KEYS)[number] | (typeof SERVICE_SCREEN_LABEL_KEYS)[number] | (typeof STYLIST_SCREEN_LABEL_KEYS)[number] | (typeof TIME_SCREEN_LABEL_KEYS)[number] | (typeof MANAGE_SCREEN_LABEL_KEYS)[number] | (typeof DETAILS_SCREEN_LABEL_KEYS)[number] | (typeof SUMMARY_BAR_LABEL_KEYS)[number] | (typeof CONFIRMATION_LABEL_KEYS)[number] | (typeof BOOKING_SKELETON_LABEL_KEYS)[number] | (typeof LOGIN_SHEET_LABEL_KEYS)[number] | (typeof PORTAL_SHELL_LABEL_KEYS)[number] | (typeof PORTAL_UNREACHABLE_LABEL_KEYS)[number] | (typeof UPCOMING_BOOKINGS_LABEL_KEYS)[number] | (typeof VISIT_HISTORY_LABEL_KEYS)[number] | (typeof REBOOK_CARDS_LABEL_KEYS)[number] | (typeof CHILD_CARDS_LABEL_KEYS)[number] | (typeof FAMILY_EDITOR_LABEL_KEYS)[number] | (typeof PROFILE_FORM_LABEL_KEYS)[number] | (typeof DATA_CONTROLS_LABEL_KEYS)[number] | (typeof LOGOUT_BUTTON_LABEL_KEYS)[number] | (typeof VIPPS_LINK_ROW_LABEL_KEYS)[number];
|
|
41
|
+
/** A complete label pack for one locale. The opt-in keys may be absent. */
|
|
42
|
+
export type BookingLabels = Record<BookingRequiredLabelKey, BookingLabel> & Partial<Record<BookingOptionalLabelKey, BookingLabel>>;
|
|
@@ -15,7 +15,7 @@ import { REBOOK_CARDS_LABEL_KEYS } from './portal/rebook-cards.js';
|
|
|
15
15
|
import { UPCOMING_BOOKINGS_LABEL_KEYS } from './portal/upcoming-bookings.js';
|
|
16
16
|
import { VIPPS_LINK_ROW_LABEL_KEYS } from './portal/vipps-link-row.js';
|
|
17
17
|
import { VISIT_HISTORY_LABEL_KEYS } from './portal/visit-history.js';
|
|
18
|
-
import { SERVICE_SCREEN_LABEL_KEYS } from './service-screen.js';
|
|
18
|
+
import { SERVICE_SCREEN_LABEL_KEYS, SERVICE_SCREEN_MULTI_LABEL_KEYS } from './service-screen.js';
|
|
19
19
|
import { STYLIST_SCREEN_LABEL_KEYS } from './stylist-screen.js';
|
|
20
20
|
import { SUMMARY_BAR_LABEL_KEYS } from './summary-bar.js';
|
|
21
21
|
import { TIME_SCREEN_LABEL_KEYS } from './time-screen.js';
|
|
@@ -33,6 +33,7 @@ export const BOOKING_LABEL_KEYS = [
|
|
|
33
33
|
...WHO_SCREEN_LABEL_KEYS,
|
|
34
34
|
...ADD_CHILD_SHEET_LABEL_KEYS,
|
|
35
35
|
...SERVICE_SCREEN_LABEL_KEYS,
|
|
36
|
+
...SERVICE_SCREEN_MULTI_LABEL_KEYS,
|
|
36
37
|
...STYLIST_SCREEN_LABEL_KEYS,
|
|
37
38
|
...TIME_SCREEN_LABEL_KEYS,
|
|
38
39
|
...MANAGE_SCREEN_LABEL_KEYS,
|
|
@@ -4,8 +4,8 @@ import { type OtpSlotsLabels, type OtpSlotsSlot } from './otp-slots.js';
|
|
|
4
4
|
import { type SlotClassNames } from './slots.js';
|
|
5
5
|
import type { BookingGuardian } from './types.js';
|
|
6
6
|
import { type VippsButtonLabels, type VippsButtonSlot, type VippsStartAction } from './vipps-button.js';
|
|
7
|
-
export declare const LOGIN_PANEL_OWN_LABEL_KEYS: readonly ["login.heading", "login.codeHeading", "login.or", "login.emailIntro", "login.emailLabel", "login.sendCode", "login.codeLabel", "login.submitCode", "login.codeHelp", "login.codeHelpVipps", "login.vippsSentTo", "login.vippsSentToUnknown", "login.cooldownWait", "login.cooldownReady", "login.resendIn", "login.resend", "login.switchEmail", "login.useEmailInstead", "login.notice.sent", "login.notice.resent", "login.notice.unreachable", "login.notice.badEmail", "login.notice.invalid", "login.notice.throttled", "login.notice.vippsInvalid", "login.notice.vippsConflict"];
|
|
8
|
-
export declare const LOGIN_PANEL_LABEL_KEYS: readonly ["login.heading", "login.codeHeading", "login.or", "login.emailIntro", "login.emailLabel", "login.sendCode", "login.codeLabel", "login.submitCode", "login.codeHelp", "login.codeHelpVipps", "login.vippsSentTo", "login.vippsSentToUnknown", "login.cooldownWait", "login.cooldownReady", "login.resendIn", "login.resend", "login.switchEmail", "login.useEmailInstead", "login.notice.sent", "login.notice.resent", "login.notice.unreachable", "login.notice.badEmail", "login.notice.invalid", "login.notice.throttled", "login.notice.vippsInvalid", "login.notice.vippsConflict", "otp.label", "vipps.button", "vipps.unavailable", "vipps.throttled"];
|
|
7
|
+
export declare const LOGIN_PANEL_OWN_LABEL_KEYS: readonly ["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"];
|
|
8
|
+
export declare const LOGIN_PANEL_LABEL_KEYS: readonly ["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", "otp.label", "vipps.button", "vipps.unavailable", "vipps.throttled"];
|
|
9
9
|
/**
|
|
10
10
|
* Placeholders: `login.codeHelp` `{email}`, `login.vippsSentTo` `{to}`,
|
|
11
11
|
* `login.cooldownWait` `{seconds}`, `login.resendIn` `{time}` («0:30»).
|
|
@@ -20,13 +20,13 @@ export type LoginPanelLabels = Record<(typeof LOGIN_PANEL_OWN_LABEL_KEYS)[number
|
|
|
20
20
|
* - `divider`: the «or» rule between Vipps and e-mail.
|
|
21
21
|
* - `links`: the row under the code form; `link`: each of its buttons.
|
|
22
22
|
*/
|
|
23
|
-
export type LoginPanelSlot = 'root' | 'notice' | 'form' | 'input' | 'submit' | 'divider' | 'links' | 'link';
|
|
23
|
+
export type LoginPanelSlot = 'root' | 'notice' | 'form' | 'input' | 'submit' | 'divider' | 'emailButton' | 'links' | 'link';
|
|
24
24
|
/** What asking for a code answered. `invalidEmail`: the address was refused. */
|
|
25
25
|
export type LoginStartResult = {
|
|
26
26
|
ok: true;
|
|
27
27
|
} | {
|
|
28
28
|
ok: false;
|
|
29
|
-
reason: 'unreachable' | 'invalidEmail';
|
|
29
|
+
reason: 'unreachable' | 'invalidEmail' | 'throttled';
|
|
30
30
|
};
|
|
31
31
|
/** What checking a code answered. `conflict`: the Vipps account belongs to another profile. */
|
|
32
32
|
export type LoginVerifyResult = {
|
|
@@ -53,6 +53,8 @@ export interface LoginPanelMemory {
|
|
|
53
53
|
sentTo: string;
|
|
54
54
|
/** When «send a new code» opens again (epoch ms); `0` before any code went out. */
|
|
55
55
|
resendAt: number;
|
|
56
|
+
/** `emailCollapsed` only: the e-mail form has been opened. */
|
|
57
|
+
emailOpen?: boolean;
|
|
56
58
|
}
|
|
57
59
|
export declare const FRESH_LOGIN_MEMORY: LoginPanelMemory;
|
|
58
60
|
/** Where a Vipps return that asks for the code starts: on the code, for `to`. */
|
|
@@ -75,6 +77,8 @@ export interface LoginPanelProps {
|
|
|
75
77
|
onVipps?: VippsStartAction;
|
|
76
78
|
/** Where a Vipps login should land. */
|
|
77
79
|
vippsNext?: string | null;
|
|
80
|
+
/** Show e-mail as a second button under Vipps; the form opens on tap. Ignored without `onVipps`. */
|
|
81
|
+
emailCollapsed?: boolean;
|
|
78
82
|
/** Replaces the Vipps button's label. */
|
|
79
83
|
vippsLabel?: string;
|
|
80
84
|
/** Start on the Vipps confirm code screen (uncontrolled memory only). */
|
|
@@ -106,4 +110,4 @@ export interface LoginPanelProps {
|
|
|
106
110
|
* waits between asks, so an impatient tap does not spend the rate-limit
|
|
107
111
|
* allowance on a code that is still valid.
|
|
108
112
|
*/
|
|
109
|
-
export declare function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps, vippsNext, vippsLabel, vippsConfirm, memory: memoryProp, onMemoryChange, heading, intro, resendCooldownMs, classNames, otpClassNames, vippsClassNames, }: LoginPanelProps): import("react").JSX.Element;
|
|
113
|
+
export declare function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps, vippsNext, vippsLabel, emailCollapsed, vippsConfirm, memory: memoryProp, onMemoryChange, heading, intro, resendCooldownMs, classNames, otpClassNames, vippsClassNames, }: LoginPanelProps): import("react").JSX.Element;
|
|
@@ -15,6 +15,7 @@ export const LOGIN_PANEL_OWN_LABEL_KEYS = [
|
|
|
15
15
|
'login.heading',
|
|
16
16
|
'login.codeHeading',
|
|
17
17
|
'login.or',
|
|
18
|
+
'login.continueEmail',
|
|
18
19
|
'login.emailIntro',
|
|
19
20
|
'login.emailLabel',
|
|
20
21
|
'login.sendCode',
|
|
@@ -56,6 +57,12 @@ export function loginMemoryFor(vippsConfirm) {
|
|
|
56
57
|
? { mode: 'vipps', email: '', sentTo: vippsConfirm.to ?? '', resendAt: 0 }
|
|
57
58
|
: FRESH_LOGIN_MEMORY;
|
|
58
59
|
}
|
|
60
|
+
/** Which notice each refused send shows. */
|
|
61
|
+
const START_NOTICE = {
|
|
62
|
+
invalidEmail: 'badEmail',
|
|
63
|
+
throttled: 'throttled',
|
|
64
|
+
unreachable: 'unreachable',
|
|
65
|
+
};
|
|
59
66
|
/** The notices that describe a mistake in the field they sit under. */
|
|
60
67
|
const FIELD_ERRORS = new Set([
|
|
61
68
|
'badEmail',
|
|
@@ -92,11 +99,11 @@ function verifyNotice(reason, vipps) {
|
|
|
92
99
|
* waits between asks, so an impatient tap does not spend the rate-limit
|
|
93
100
|
* allowance on a code that is still valid.
|
|
94
101
|
*/
|
|
95
|
-
export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps, vippsNext = null, vippsLabel, vippsConfirm = null, memory: memoryProp, onMemoryChange, heading = () => null, intro, resendCooldownMs = 30_000, classNames, otpClassNames, vippsClassNames, }) {
|
|
102
|
+
export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps, vippsNext = null, vippsLabel, emailCollapsed = false, vippsConfirm = null, memory: memoryProp, onMemoryChange, heading = () => null, intro, resendCooldownMs = 30_000, classNames, otpClassNames, vippsClassNames, }) {
|
|
96
103
|
const id = useId();
|
|
97
104
|
const [heldHere, setHeldHere] = useState(() => loginMemoryFor(vippsConfirm));
|
|
98
105
|
const controlled = memoryProp !== undefined && onMemoryChange !== undefined;
|
|
99
|
-
const { mode, email, sentTo, resendAt } = controlled ? memoryProp : heldHere;
|
|
106
|
+
const { mode, email, sentTo, resendAt, emailOpen = false } = controlled ? memoryProp : heldHere;
|
|
100
107
|
const setMemory = controlled ? onMemoryChange : setHeldHere;
|
|
101
108
|
const remember = (patch) => setMemory((previous) => ({ ...previous, ...patch }));
|
|
102
109
|
const setEmail = (next) => remember({ email: next });
|
|
@@ -112,6 +119,10 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
|
|
|
112
119
|
const verifying = useRef(false);
|
|
113
120
|
const [checking, setChecking] = useState(false);
|
|
114
121
|
const codeRef = useRef(null);
|
|
122
|
+
const emailRef = useRef(null);
|
|
123
|
+
const collapsed = emailCollapsed && !!onVipps && !emailOpen;
|
|
124
|
+
/** Focus only after a tap opened the form, not when memory restores it. */
|
|
125
|
+
const focusEmail = useRef(false);
|
|
115
126
|
const emailId = `${id}-email`;
|
|
116
127
|
const codeId = `${id}-code`;
|
|
117
128
|
const noticeId = `${id}-notice`;
|
|
@@ -136,6 +147,12 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
|
|
|
136
147
|
setFocusCode(false);
|
|
137
148
|
codeRef.current?.querySelector('input')?.focus();
|
|
138
149
|
}, [focusCode, pending, mode]);
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (!focusEmail.current || collapsed)
|
|
152
|
+
return;
|
|
153
|
+
focusEmail.current = false;
|
|
154
|
+
emailRef.current?.focus();
|
|
155
|
+
}, [collapsed]);
|
|
139
156
|
function requestCode(address, resend) {
|
|
140
157
|
startTransition(async () => {
|
|
141
158
|
let result;
|
|
@@ -146,7 +163,7 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
|
|
|
146
163
|
result = { ok: false, reason: 'unreachable' };
|
|
147
164
|
}
|
|
148
165
|
if (!result.ok) {
|
|
149
|
-
setNotice(result.reason
|
|
166
|
+
setNotice(START_NOTICE[result.reason]);
|
|
150
167
|
return;
|
|
151
168
|
}
|
|
152
169
|
const sentAt = Date.now();
|
|
@@ -206,7 +223,8 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
|
|
|
206
223
|
verify(code);
|
|
207
224
|
}
|
|
208
225
|
function switchEmail() {
|
|
209
|
-
|
|
226
|
+
// They already chose e-mail: back to the open form, not the two buttons.
|
|
227
|
+
remember({ mode: 'start', emailOpen: true });
|
|
210
228
|
setCode('');
|
|
211
229
|
setNotice(null);
|
|
212
230
|
}
|
|
@@ -228,7 +246,10 @@ export function LoginPanel({ labels, onStartLogin, onVerify, onSignedIn, onVipps
|
|
|
228
246
|
const link = slotClass(classNames, 'link', 'font-semibold text-primary underline underline-offset-4 disabled:no-underline disabled:opacity-60');
|
|
229
247
|
return (_jsxs("div", { className: slotClass(classNames, 'root', 'space-y-6'), children: [heading(renderLabel(labels[mode === 'start' ? 'login.heading' : 'login.codeHeading'])), mode === 'vipps' && (_jsx("p", { className: "text-muted-foreground", children: sentTo === ''
|
|
230
248
|
? labels['login.vippsSentToUnknown']
|
|
231
|
-
: renderLabel(labels['login.vippsSentTo'], { to: sentTo }) })), liveRegion, cooldownRegion, mode === 'start' ? (_jsxs(_Fragment, { children: [intro, onVipps && (_jsxs(_Fragment, { children: [_jsx(VippsButton, { onVipps: onVipps, next: vippsNext, label: vippsLabel, labels: labels, classNames: vippsClassNames }), _jsxs("div", { className: slotClass(classNames, 'divider', 'flex items-center gap-4 text-sm text-muted-foreground'), "aria-hidden": "true", children: [_jsx("span", { className: "h-px flex-1 bg-border" }), labels['login.or'], _jsx("span", { className: "h-px flex-1 bg-border" })] })
|
|
249
|
+
: renderLabel(labels['login.vippsSentTo'], { to: sentTo }) })), liveRegion, cooldownRegion, mode === 'start' ? (_jsxs(_Fragment, { children: [!collapsed && intro, onVipps && (_jsxs(_Fragment, { children: [_jsx(VippsButton, { onVipps: onVipps, next: vippsNext, label: vippsLabel, labels: labels, classNames: vippsClassNames }), !emailCollapsed && (_jsxs("div", { className: slotClass(classNames, 'divider', 'flex items-center gap-4 text-sm text-muted-foreground'), "aria-hidden": "true", children: [_jsx("span", { className: "h-px flex-1 bg-border" }), labels['login.or'], _jsx("span", { className: "h-px flex-1 bg-border" })] }))] })), collapsed ? (_jsx(BookingButton, { type: "button", variant: "outline", size: "lg", className: slotClass(classNames, 'emailButton', 'w-full'), onClick: () => {
|
|
250
|
+
focusEmail.current = true;
|
|
251
|
+
remember({ emailOpen: true });
|
|
252
|
+
}, children: labels['login.continueEmail'] })) : (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-muted-foreground", children: labels['login.emailIntro'] }), _jsxs("form", { onSubmit: submitEmail, className: slotClass(classNames, 'form', 'space-y-6'), children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: emailId, children: labels['login.emailLabel'] }), _jsx(Input, { ref: emailRef, id: emailId, type: "email", autoComplete: "email", required: true, value: email, "aria-describedby": noticeText ? noticeId : undefined, "aria-invalid": (isFieldError && notice !== 'throttled') || undefined, onChange: (event) => setEmail(event.target.value), className: slotClass(classNames, 'input', BOOKING_INPUT_CLASS) })] }), _jsx(BookingButton, { type: "submit", size: "lg", className: slotClass(classNames, 'submit', 'w-full'), disabled: pending, children: labels['login.sendCode'] })] })] }))] })) : (_jsxs(_Fragment, { children: [_jsxs("form", { onSubmit: submitCode, className: slotClass(classNames, 'form', 'space-y-6'), children: [_jsxs(Field, { children: [_jsx(Field.Label, { htmlFor: codeId, children: labels['login.codeLabel'] }), _jsx("div", { ref: codeRef, children: _jsx(OtpSlots, { id: codeId, value: code,
|
|
232
253
|
// Read-only rather than disabled while a request is out, so
|
|
233
254
|
// the caret stays in the field instead of falling out of a
|
|
234
255
|
// dialog; a wrong code then only has to empty it.
|
|
@@ -3,7 +3,7 @@ import { type LoginPanelLabels, type LoginPanelProps } from './login-panel.js';
|
|
|
3
3
|
import { type SlotClassNames } from './slots.js';
|
|
4
4
|
import type { BookingGuardian } from './types.js';
|
|
5
5
|
export declare const LOGIN_SHEET_OWN_LABEL_KEYS: readonly ["loginSheet.prompt", "loginSheet.trigger", "loginSheet.intro", "loginSheet.vipps", "loginSheet.close"];
|
|
6
|
-
export declare const LOGIN_SHEET_LABEL_KEYS: readonly ["loginSheet.prompt", "loginSheet.trigger", "loginSheet.intro", "loginSheet.vipps", "loginSheet.close", "login.heading", "login.codeHeading", "login.or", "login.emailIntro", "login.emailLabel", "login.sendCode", "login.codeLabel", "login.submitCode", "login.codeHelp", "login.codeHelpVipps", "login.vippsSentTo", "login.vippsSentToUnknown", "login.cooldownWait", "login.cooldownReady", "login.resendIn", "login.resend", "login.switchEmail", "login.useEmailInstead", "login.notice.sent", "login.notice.resent", "login.notice.unreachable", "login.notice.badEmail", "login.notice.invalid", "login.notice.throttled", "login.notice.vippsInvalid", "login.notice.vippsConflict", "otp.label", "vipps.button", "vipps.unavailable", "vipps.throttled"];
|
|
6
|
+
export declare const LOGIN_SHEET_LABEL_KEYS: readonly ["loginSheet.prompt", "loginSheet.trigger", "loginSheet.intro", "loginSheet.vipps", "loginSheet.close", "login.heading", "login.codeHeading", "login.or", "login.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", "otp.label", "vipps.button", "vipps.unavailable", "vipps.throttled"];
|
|
7
7
|
/** `loginSheet.prompt` holds a `{trigger}` placeholder where the trigger button goes. */
|
|
8
8
|
export type LoginSheetLabels = Record<(typeof LOGIN_SHEET_OWN_LABEL_KEYS)[number], BookingLabel> & LoginPanelLabels;
|
|
9
9
|
/**
|
|
@@ -9,9 +9,32 @@ import type { BookingServiceDto } from './types.js';
|
|
|
9
9
|
* Presentational. A tap raises `onPick` and the caller's machine decides what
|
|
10
10
|
* that means; nothing here knows which step comes next or whether the basket
|
|
11
11
|
* already holds a child.
|
|
12
|
+
*
|
|
13
|
+
* With a `selection`, the step is multi-select instead: one person may have
|
|
14
|
+
* several services as ONE visit, the cards tick rather than answer, and a
|
|
15
|
+
* sticky bar carries the running total and the way on.
|
|
12
16
|
*/
|
|
13
17
|
export declare const SERVICE_SCREEN_LABEL_KEYS: readonly ["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"];
|
|
14
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The keys only the multi-select step reads (`ServiceScreenProps.selection`).
|
|
20
|
+
* Optional in `ServiceScreenLabels`, so a pack written before they existed
|
|
21
|
+
* still type-checks; a `selection` carries them all, required, in
|
|
22
|
+
* `ServiceSelection.labels` — a multi-select step cannot compile without them.
|
|
23
|
+
*
|
|
24
|
+
* - `service.multiHint` — under the heading: you may tick more than one;
|
|
25
|
+
* - `service.total` — the sticky bar's running total, `{minutes}` and `{price}`;
|
|
26
|
+
* - `service.chooseFirst` — the sticky bar's line while someone has nothing ticked;
|
|
27
|
+
* - `service.continue` — the sticky bar's button;
|
|
28
|
+
* - `service.party.nothingBookable` — a family member with nothing on their
|
|
29
|
+
* menu, `{label}`: the business takes nothing for them online (the party's
|
|
30
|
+
* size is no longer the reason, as `service.party.nothingFits` says);
|
|
31
|
+
* - `service.tabDone` — read after a family member's tab name once they have
|
|
32
|
+
* something ticked (the visible tick, in words): «Theo, ferdig».
|
|
33
|
+
*/
|
|
34
|
+
export declare const SERVICE_SCREEN_MULTI_LABEL_KEYS: readonly ["service.multiHint", "service.total", "service.chooseFirst", "service.continue", "service.party.nothingBookable", "service.tabDone"];
|
|
35
|
+
/** The multi-select step's labels, every one required (`ServiceSelection.labels`). */
|
|
36
|
+
export type ServiceMultiLabels = Record<(typeof SERVICE_SCREEN_MULTI_LABEL_KEYS)[number], BookingLabel>;
|
|
37
|
+
export type ServiceScreenLabels = Record<(typeof SERVICE_SCREEN_LABEL_KEYS)[number], BookingLabel> & Partial<ServiceMultiLabels>;
|
|
15
38
|
/**
|
|
16
39
|
* - `root` — the `<section>`
|
|
17
40
|
* - `heading` — the `<h2>`
|
|
@@ -19,8 +42,10 @@ export type ServiceScreenLabels = Record<(typeof SERVICE_SCREEN_LABEL_KEYS)[numb
|
|
|
19
42
|
* - `groupHeading` — a category's (or a party member's) `<h3>`
|
|
20
43
|
* - `card` / `cardSelected` — a service card / a pressed one (passed to `ServiceCard`)
|
|
21
44
|
* - `price` — a card's price (passed to `ServiceCard`, and the «same as last time» card's)
|
|
45
|
+
* - `tab` / `tabSelected` — a person's tab in a multi-select family / the open one
|
|
46
|
+
* - `bar` — the multi-select step's sticky total bar
|
|
22
47
|
*/
|
|
23
|
-
export type ServiceScreenSlot = 'root' | 'heading' | 'pill' | 'pillSelected' | 'groupHeading' | 'card' | 'cardSelected' | 'price';
|
|
48
|
+
export type ServiceScreenSlot = 'tab' | 'tabSelected' | 'bar' | 'root' | 'heading' | 'pill' | 'pillSelected' | 'groupHeading' | 'card' | 'cardSelected' | 'price';
|
|
24
49
|
/** One service group, in display order. */
|
|
25
50
|
export interface ServiceScreenCategory {
|
|
26
51
|
key: string;
|
|
@@ -51,6 +76,13 @@ export interface ServiceCardProps {
|
|
|
51
76
|
*/
|
|
52
77
|
kind: 'book' | 'choice' | 'phone';
|
|
53
78
|
selected: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* `choice` only: the card is one of several the person may tick (a
|
|
81
|
+
* checkbox, `selected` = ticked), not their one answer (a pressed button).
|
|
82
|
+
* Set on the multi-select step (`ServiceScreenProps.selection`).
|
|
83
|
+
*/
|
|
84
|
+
multiple?: boolean;
|
|
85
|
+
/** Raised on a tap. On a `multiple` card it toggles; nothing advances. */
|
|
54
86
|
onPick: () => void;
|
|
55
87
|
/** The number for `phone` cards; `null` renders the sentence without a link. */
|
|
56
88
|
phone: string | null;
|
|
@@ -61,6 +93,40 @@ export interface ServiceCardProps {
|
|
|
61
93
|
/** Merged onto the price (the `price` slot). */
|
|
62
94
|
priceClassName?: string;
|
|
63
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* The multi-select step: each person ticks one or more services, done back to
|
|
98
|
+
* back as one visit. The caller's machine owns the lists; the screen only
|
|
99
|
+
* reports taps.
|
|
100
|
+
*/
|
|
101
|
+
export interface ServiceSelection {
|
|
102
|
+
/** Each person's services so far, in order (index = party person; a lone person is index 0). */
|
|
103
|
+
lists: ReadonlyArray<ReadonlyArray<{
|
|
104
|
+
id: string;
|
|
105
|
+
}>>;
|
|
106
|
+
/** A tick or an untick on the person whose tab is open (0 for a lone person). Never advances. */
|
|
107
|
+
onToggle: (personIndex: number, service: BookingServiceDto) => void;
|
|
108
|
+
/** «Next» in the sticky bar. Called whether or not `canContinue`: the caller decides. */
|
|
109
|
+
onContinue: () => void;
|
|
110
|
+
/** The running total for the whole booking, or `null` when nothing is chosen. */
|
|
111
|
+
total: {
|
|
112
|
+
minutes: number;
|
|
113
|
+
priceOre: number;
|
|
114
|
+
} | null;
|
|
115
|
+
/**
|
|
116
|
+
* Whether every person has at least one service. «Next» stays enabled
|
|
117
|
+
* either way (a disabled button explains nothing); while `false` the bar
|
|
118
|
+
* shows `service.chooseFirst` instead of the total.
|
|
119
|
+
*/
|
|
120
|
+
canContinue: boolean;
|
|
121
|
+
/** A refusal to say politely (e.g. too many services), announced as a live region. */
|
|
122
|
+
notice?: string | null;
|
|
123
|
+
/**
|
|
124
|
+
* The multi-select step's labels (`SERVICE_SCREEN_MULTI_LABEL_KEYS`), all
|
|
125
|
+
* required: a pack written before multi-select cannot reach this step and
|
|
126
|
+
* draw an unnamed «Next». A pack that defines them can be passed whole.
|
|
127
|
+
*/
|
|
128
|
+
labels: ServiceMultiLabels;
|
|
129
|
+
}
|
|
64
130
|
export interface ServiceScreenComponents {
|
|
65
131
|
ServiceCard?: ComponentType<ServiceCardProps>;
|
|
66
132
|
}
|
|
@@ -126,6 +192,14 @@ export interface ServiceScreenProps {
|
|
|
126
192
|
possessive?: (name: string) => string;
|
|
127
193
|
classNames?: SlotClassNames<ServiceScreenSlot>;
|
|
128
194
|
components?: ServiceScreenComponents;
|
|
195
|
+
/**
|
|
196
|
+
* Switches the step to multi-select (see `ServiceSelection`): bookable cards
|
|
197
|
+
* become checkboxes, a family gets one tab per person, and a sticky bar
|
|
198
|
+
* shows the total and «Next». `onPick` / `party.onPickFor` then serve only
|
|
199
|
+
* the one-tap «same as last time». Its labels come in `selection.labels`.
|
|
200
|
+
* Absent: the one-tap step, unchanged.
|
|
201
|
+
*/
|
|
202
|
+
selection?: ServiceSelection;
|
|
129
203
|
}
|
|
130
204
|
export declare function ServiceScreen(props: ServiceScreenProps): import("react").JSX.Element;
|
|
131
205
|
/**
|
|
@@ -138,4 +212,4 @@ export declare function ServiceScreen(props: ServiceScreenProps): import("react"
|
|
|
138
212
|
* online, and a tap target that accepts the tap and does nothing is the dead
|
|
139
213
|
* end the phone number replaces. The card stays so the catalogue is whole.
|
|
140
214
|
*/
|
|
141
|
-
export declare function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, priceClassName, }: ServiceCardProps): import("react").JSX.Element;
|
|
215
|
+
export declare function DefaultServiceCard({ service, kind, selected, multiple, onPick, phone, format, labels, className, selectedClassName, priceClassName, }: ServiceCardProps): import("react").JSX.Element;
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
import { cn } from '../lib/utils.js';
|
|
5
5
|
import { renderLabel } from './internal/label-parts.js';
|
|
6
|
+
import { MultiServiceCard, PersonTabs, serviceTabId, serviceTabPanelId, TotalBar, } from './internal/service-multi.js';
|
|
6
7
|
import { BookingButton } from './internal/ui.js';
|
|
7
8
|
import { fillLabel, labelText } from './labels.js';
|
|
8
9
|
import { slotClass } from './slots.js';
|
|
@@ -12,6 +13,10 @@ import { slotClass } from './slots.js';
|
|
|
12
13
|
* Presentational. A tap raises `onPick` and the caller's machine decides what
|
|
13
14
|
* that means; nothing here knows which step comes next or whether the basket
|
|
14
15
|
* already holds a child.
|
|
16
|
+
*
|
|
17
|
+
* With a `selection`, the step is multi-select instead: one person may have
|
|
18
|
+
* several services as ONE visit, the cards tick rather than answer, and a
|
|
19
|
+
* sticky bar carries the running total and the way on.
|
|
15
20
|
*/
|
|
16
21
|
export const SERVICE_SCREEN_LABEL_KEYS = [
|
|
17
22
|
'service.heading',
|
|
@@ -28,10 +33,35 @@ export const SERVICE_SCREEN_LABEL_KEYS = [
|
|
|
28
33
|
'service.party.nothingFits',
|
|
29
34
|
'service.party.remove',
|
|
30
35
|
];
|
|
36
|
+
/**
|
|
37
|
+
* The keys only the multi-select step reads (`ServiceScreenProps.selection`).
|
|
38
|
+
* Optional in `ServiceScreenLabels`, so a pack written before they existed
|
|
39
|
+
* still type-checks; a `selection` carries them all, required, in
|
|
40
|
+
* `ServiceSelection.labels` — a multi-select step cannot compile without them.
|
|
41
|
+
*
|
|
42
|
+
* - `service.multiHint` — under the heading: you may tick more than one;
|
|
43
|
+
* - `service.total` — the sticky bar's running total, `{minutes}` and `{price}`;
|
|
44
|
+
* - `service.chooseFirst` — the sticky bar's line while someone has nothing ticked;
|
|
45
|
+
* - `service.continue` — the sticky bar's button;
|
|
46
|
+
* - `service.party.nothingBookable` — a family member with nothing on their
|
|
47
|
+
* menu, `{label}`: the business takes nothing for them online (the party's
|
|
48
|
+
* size is no longer the reason, as `service.party.nothingFits` says);
|
|
49
|
+
* - `service.tabDone` — read after a family member's tab name once they have
|
|
50
|
+
* something ticked (the visible tick, in words): «Theo, ferdig».
|
|
51
|
+
*/
|
|
52
|
+
export const SERVICE_SCREEN_MULTI_LABEL_KEYS = [
|
|
53
|
+
'service.multiHint',
|
|
54
|
+
'service.total',
|
|
55
|
+
'service.chooseFirst',
|
|
56
|
+
'service.continue',
|
|
57
|
+
'service.party.nothingBookable',
|
|
58
|
+
'service.tabDone',
|
|
59
|
+
];
|
|
31
60
|
const HEADING_ID = 'booking-service-heading';
|
|
32
61
|
function groupHeadingId(category) {
|
|
33
62
|
return `booking-services-${category}`;
|
|
34
63
|
}
|
|
64
|
+
const HINT_ID = 'booking-service-hint';
|
|
35
65
|
const allFit = () => true;
|
|
36
66
|
const asIs = (name) => name;
|
|
37
67
|
/** The age divider's sentence for `name`. */
|
|
@@ -61,6 +91,10 @@ function cardProps(shared) {
|
|
|
61
91
|
priceClassName: shared.classNames?.price,
|
|
62
92
|
};
|
|
63
93
|
}
|
|
94
|
+
/** The ids `personIndex` has ticked so far. */
|
|
95
|
+
function tickedBy(selection, personIndex) {
|
|
96
|
+
return new Set((selection.lists[personIndex] ?? []).map((service) => service.id));
|
|
97
|
+
}
|
|
64
98
|
export function ServiceScreen(props) {
|
|
65
99
|
const shared = {
|
|
66
100
|
labels: props.labels,
|
|
@@ -74,8 +108,13 @@ export function ServiceScreen(props) {
|
|
|
74
108
|
}
|
|
75
109
|
return _jsx(SingleServiceScreen, { ...props, shared: shared });
|
|
76
110
|
}
|
|
77
|
-
function Heading({ shared }) {
|
|
78
|
-
|
|
111
|
+
function Heading({ shared, multiLabels = null, }) {
|
|
112
|
+
const heading = (_jsx("h2", { id: HEADING_ID, tabIndex: -1, className: slotClass(shared.classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: shared.labels['service.heading'] }));
|
|
113
|
+
if (!multiLabels)
|
|
114
|
+
return heading;
|
|
115
|
+
// «Choose one or more» — said once, under the question, so the first tick
|
|
116
|
+
// is not a surprise when it does not move the step on.
|
|
117
|
+
return (_jsxs("div", { className: "space-y-1", children: [heading, _jsx("p", { id: HINT_ID, className: "text-sm text-muted-foreground", children: renderLabel(multiLabels['service.multiHint']) })] }));
|
|
79
118
|
}
|
|
80
119
|
/**
|
|
81
120
|
* Below the ordinary list: what usually does not suit this person, still one
|
|
@@ -87,8 +126,18 @@ function UnlikelyForAge({ id, name, services, shared, children, }) {
|
|
|
87
126
|
return null;
|
|
88
127
|
return (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center gap-3 pt-2", children: [_jsx("span", { "aria-hidden": "true", className: "h-px flex-1 bg-border" }), _jsx("p", { id: id, className: "text-sm text-muted-foreground", children: ageDividerLabel(shared.labels, name, shared.possessive) }), _jsx("span", { "aria-hidden": "true", className: "h-px flex-1 bg-border" })] }), _jsx("ul", { "aria-labelledby": id, className: "space-y-2", children: services.map((service) => (_jsx("li", { children: children(service) }, service.id))) })] }));
|
|
89
128
|
}
|
|
90
|
-
function SingleServiceScreen({ services, categories, categoryOf, serviceFits = allFit, phone = null, initialCategory = null, onPick, suggestion = null, chosenId = null, childName = null, shared, }) {
|
|
129
|
+
function SingleServiceScreen({ services, categories, categoryOf, serviceFits = allFit, phone = null, initialCategory = null, onPick, suggestion = null, chosenId = null, childName = null, selection, shared, }) {
|
|
91
130
|
const { Card, labels, classNames } = shared;
|
|
131
|
+
const ticked = selection ? tickedBy(selection, 0) : null;
|
|
132
|
+
// One person: a tap either answers the step or, multi-select, ticks.
|
|
133
|
+
const bookable = (service) => ticked && selection
|
|
134
|
+
? {
|
|
135
|
+
kind: 'choice',
|
|
136
|
+
multiple: true,
|
|
137
|
+
selected: ticked.has(service.id),
|
|
138
|
+
onPick: () => selection.onToggle(0, service),
|
|
139
|
+
}
|
|
140
|
+
: { kind: 'book', selected: false, onPick: () => onPick(service) };
|
|
92
141
|
const fileUnder = categoryOf ?? defaultCategoryOf(categories);
|
|
93
142
|
// What certainly does not suit this person moves below a divider, still
|
|
94
143
|
// bookable. Telephone-only cards stay where they are: the list stays whole.
|
|
@@ -119,7 +168,7 @@ function SingleServiceScreen({ services, categories, categoryOf, serviceFits = a
|
|
|
119
168
|
block: 'start',
|
|
120
169
|
});
|
|
121
170
|
}
|
|
122
|
-
return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx(Heading, { shared: shared }), suggestion && (_jsxs("div", { className: "space-y-2", children: [_jsx(SameAsLast, { suggestion: suggestion, selected: chosenId === suggestion.service.id, onPick: () => onPick(suggestion.service), shared: shared }), _jsx("p", { className: "text-sm font-semibold text-muted-foreground", children: labels['service.chooseOther'] })] })), groups.length > 1 && (_jsxs("fieldset", { className: "flex flex-wrap gap-2", children: [_jsx("legend", { className: "sr-only", children: labels['service.categoriesLegend'] }), groups.map(({ category }) => {
|
|
171
|
+
return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx(Heading, { shared: shared, multiLabels: selection?.labels }), suggestion && (_jsxs("div", { className: "space-y-2", children: [_jsx(SameAsLast, { suggestion: suggestion, selected: ticked ? ticked.has(suggestion.service.id) : chosenId === suggestion.service.id, onPick: () => onPick(suggestion.service), shared: shared }), _jsx("p", { className: "text-sm font-semibold text-muted-foreground", children: labels['service.chooseOther'] })] })), groups.length > 1 && (_jsxs("fieldset", { className: "flex flex-wrap gap-2", children: [_jsx("legend", { className: "sr-only", children: labels['service.categoriesLegend'] }), groups.map(({ category }) => {
|
|
123
172
|
const marked = category.key === current;
|
|
124
173
|
return (_jsx(BookingButton, { variant: "outline", size: "sm", "aria-pressed": marked, onClick: () => jumpTo(category.key),
|
|
125
174
|
// Weight and ring carry the selection alongside the colour, so
|
|
@@ -129,7 +178,9 @@ function SingleServiceScreen({ services, categories, categoryOf, serviceFits = a
|
|
|
129
178
|
: 'font-normal', classNames?.pill, marked && classNames?.pillSelected), children: category.label }, category.key));
|
|
130
179
|
})] })), groups.map(({ category, items }) => (_jsxs("section", { "aria-labelledby": groupHeadingId(category.key),
|
|
131
180
|
// Clears a sticky header when a pill jumps here.
|
|
132
|
-
className: "scroll-mt-24 space-y-3", children: [_jsx("h3", { id: groupHeadingId(category.key), className: slotClass(classNames, 'groupHeading', 'font-sans text-lg font-bold text-muted-foreground'), children: category.label }), _jsx("ul", { className: "space-y-2", children: items.map((service) => (_jsx("li", { children: _jsx(Card, { service: service,
|
|
181
|
+
className: "scroll-mt-24 space-y-3", children: [_jsx("h3", { id: groupHeadingId(category.key), className: slotClass(classNames, 'groupHeading', 'font-sans text-lg font-bold text-muted-foreground'), children: category.label }), _jsx("ul", { "aria-describedby": selection ? HINT_ID : undefined, className: "space-y-2", children: items.map((service) => (_jsx("li", { children: _jsx(Card, { service: service, ...(service.bookableOnline
|
|
182
|
+
? bookable(service)
|
|
183
|
+
: { kind: 'phone', selected: false, onPick: () => onPick(service) }), phone: phone, ...cardProps(shared) }) }, service.id))) })] }, category.key))), _jsx(UnlikelyForAge, { id: "booking-services-unlikely", name: childName, services: unlikely, shared: shared, children: (service) => (_jsx(Card, { service: service, ...bookable(service), phone: phone, ...cardProps(shared) })) }), selection && (_jsx(TotalBar, { selection: selection, format: shared.format, classNames: classNames }))] }));
|
|
133
184
|
}
|
|
134
185
|
function defaultCategoryOf(categories) {
|
|
135
186
|
const known = new Set(categories.map((category) => category.key));
|
|
@@ -145,51 +196,77 @@ function defaultCategoryOf(categories) {
|
|
|
145
196
|
* a `childCategory`, a child gets that menu and an adult everything else,
|
|
146
197
|
* which is often nothing, and is told so.
|
|
147
198
|
*/
|
|
148
|
-
function PartyServiceScreen({ services, categories, categoryOf, childCategory, serviceFits = allFit, party, shared, }) {
|
|
199
|
+
function PartyServiceScreen({ services, categories, categoryOf, childCategory, serviceFits = allFit, party, selection, shared, }) {
|
|
149
200
|
const { Card, labels, classNames } = shared;
|
|
150
201
|
const fileUnder = categoryOf ?? defaultCategoryOf(categories);
|
|
151
202
|
const size = party.people.length;
|
|
152
|
-
const
|
|
203
|
+
const [active, setActive] = useState(0);
|
|
204
|
+
// Multi-select: the engine books a grown-up's visit alongside the
|
|
205
|
+
// children's, so the party's size no longer narrows the menu; a refusal
|
|
206
|
+
// comes back from the caller as `selection.notice`.
|
|
207
|
+
const fits = services.filter((service) => service.bookableOnline && (selection !== undefined || service.maxPerBooking >= size));
|
|
208
|
+
function personBody(person, index) {
|
|
209
|
+
const { fitting: menu, unlikely } = byFit(
|
|
210
|
+
// A grown-up gets the grown-ups' menu and a child the children's —
|
|
211
|
+
// a kids' cut for «myself» is not an answer, it is a way round the
|
|
212
|
+
// note below that books the wrong thing.
|
|
213
|
+
childCategory === undefined
|
|
214
|
+
? fits
|
|
215
|
+
: fits.filter((service) => (fileUnder(service) === childCategory) === !person.adult), (service) => serviceFits(service, index));
|
|
216
|
+
const ticked = selection ? tickedBy(selection, index) : null;
|
|
217
|
+
const chosen = party.choices[index]?.id ?? null;
|
|
218
|
+
const suggestion = person.suggestion ?? null;
|
|
219
|
+
const pick = (service) => party.onPickFor(index, service);
|
|
220
|
+
const choiceCard = (service) => (_jsx(Card, { service: service, kind: "choice", ...(ticked && selection
|
|
221
|
+
? {
|
|
222
|
+
multiple: true,
|
|
223
|
+
selected: ticked.has(service.id),
|
|
224
|
+
onPick: () => selection.onToggle(index, service),
|
|
225
|
+
}
|
|
226
|
+
: { selected: service.id === chosen, onPick: () => pick(service) }), phone: null, ...cardProps(shared) }));
|
|
227
|
+
const unlikelyList = (_jsx(UnlikelyForAge, { id: `booking-service-unlikely-${index}`, name: person.name, services: unlikely, shared: shared, children: choiceCard }));
|
|
228
|
+
const listLabel = fillLabel(labels['service.party.listLabel'], { label: person.label });
|
|
229
|
+
if (suggestion && menu.some((service) => service.id === suggestion.service.id)) {
|
|
230
|
+
return (_jsx(PersonWithSuggestion
|
|
231
|
+
// A fresh disclosure per person: the tabs share one panel.
|
|
232
|
+
, { listLabel: listLabel, suggestion: suggestion, menu: menu, ticked: ticked, chosen: chosen, onPick: pick, choiceCard: choiceCard, unlikely: unlikelyList, hasUnlikely: unlikely.length > 0, shared: shared }, person.key));
|
|
233
|
+
}
|
|
234
|
+
if (menu.length === 0 && unlikely.length === 0) {
|
|
235
|
+
return (_jsx(NothingInThisParty, { person: person, labels: labels, multiLabels: selection?.labels ?? null, onRemove: party.onRemove ? () => party.onRemove?.(index) : undefined }));
|
|
236
|
+
}
|
|
237
|
+
return (_jsxs(_Fragment, { children: [menu.length > 0 && (_jsx("ul", { "aria-label": listLabel, className: "space-y-2", children: menu.map((service) => (_jsx("li", { children: choiceCard(service) }, service.id))) })), unlikelyList] }));
|
|
238
|
+
}
|
|
239
|
+
if (selection) {
|
|
240
|
+
// A tab that left the party falls back to the last one still in it.
|
|
241
|
+
const current = Math.min(active, size - 1);
|
|
242
|
+
return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx(Heading, { shared: shared, multiLabels: selection.labels }), _jsx(PersonTabs, { people: party.people, active: current, done: (index) => (selection.lists[index]?.length ?? 0) > 0, onSelect: setActive, labels: selection.labels, labelledBy: HEADING_ID, describedBy: HINT_ID, classNames: classNames }), party.people.map((person, index) => index === current ? (_jsx("section", { role: "tabpanel", id: serviceTabPanelId(index), "aria-labelledby": serviceTabId(index), className: "space-y-3", children: personBody(person, index) }, person.key)) : null), _jsx(TotalBar, { selection: selection, format: shared.format, classNames: classNames })] }));
|
|
243
|
+
}
|
|
153
244
|
return (_jsxs("section", { "aria-labelledby": HEADING_ID, className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx(Heading, { shared: shared }), party.people.map((person, index) => {
|
|
154
245
|
const headingId = `booking-service-for-${index}`;
|
|
155
|
-
|
|
156
|
-
// A grown-up gets the grown-ups' menu and a child the children's —
|
|
157
|
-
// a kids' cut for «myself» is not an answer, it is a way round the
|
|
158
|
-
// note below that books the wrong thing.
|
|
159
|
-
childCategory === undefined
|
|
160
|
-
? fits
|
|
161
|
-
: fits.filter((service) => (fileUnder(service) === childCategory) === !person.adult), (service) => serviceFits(service, index));
|
|
162
|
-
const chosen = party.choices[index]?.id ?? null;
|
|
163
|
-
const suggestion = person.suggestion ?? null;
|
|
164
|
-
const pick = (service) => party.onPickFor(index, service);
|
|
165
|
-
const choiceCard = (service) => (_jsx(Card, { service: service, kind: "choice", selected: service.id === chosen, onPick: () => pick(service), phone: null, ...cardProps(shared) }));
|
|
166
|
-
const unlikelyList = (_jsx(UnlikelyForAge, { id: `booking-service-unlikely-${index}`, name: person.name, services: unlikely, shared: shared, children: choiceCard }));
|
|
167
|
-
const listLabel = fillLabel(labels['service.party.listLabel'], { label: person.label });
|
|
168
|
-
let body;
|
|
169
|
-
if (suggestion && menu.some((service) => service.id === suggestion.service.id)) {
|
|
170
|
-
body = (_jsx(PersonWithSuggestion, { listLabel: listLabel, suggestion: suggestion, menu: menu, chosen: chosen, onPick: pick, choiceCard: choiceCard, unlikely: unlikelyList, hasUnlikely: unlikely.length > 0, shared: shared }));
|
|
171
|
-
}
|
|
172
|
-
else if (menu.length === 0 && unlikely.length === 0) {
|
|
173
|
-
body = (_jsx(NothingInThisParty, { person: person, labels: labels, onRemove: party.onRemove ? () => party.onRemove?.(index) : undefined }));
|
|
174
|
-
}
|
|
175
|
-
else {
|
|
176
|
-
body = (_jsxs(_Fragment, { children: [menu.length > 0 && (_jsx("ul", { "aria-label": listLabel, className: "space-y-2", children: menu.map((service) => (_jsx("li", { children: choiceCard(service) }, service.id))) })), unlikelyList] }));
|
|
177
|
-
}
|
|
178
|
-
return (_jsxs("section", { "aria-labelledby": headingId, className: "space-y-3", children: [_jsx("h3", { id: headingId, className: slotClass(classNames, 'groupHeading', 'font-sans text-lg font-bold'), children: person.label }), body] }, person.key));
|
|
246
|
+
return (_jsxs("section", { "aria-labelledby": headingId, className: "space-y-3", children: [_jsx("h3", { id: headingId, className: slotClass(classNames, 'groupHeading', 'font-sans text-lg font-bold'), children: person.label }), personBody(person, index)] }, person.key));
|
|
179
247
|
})] }));
|
|
180
248
|
}
|
|
181
249
|
/**
|
|
182
|
-
* Nothing can be booked for this person
|
|
250
|
+
* Nothing can be booked for this person.
|
|
183
251
|
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
252
|
+
* On the one-tap step that is IN A PARTY THIS SIZE — the engine's rule: one
|
|
253
|
+
* request holds at most as many people as the STRICTEST `maxPerBooking` among
|
|
254
|
+
* its services allows, so a one-per-booking adult service cannot ride along
|
|
255
|
+
* with the children's. On the multi-select step the size narrows nothing, so
|
|
256
|
+
* the reason is plainer: nothing on their menu is taken online. Either way the
|
|
257
|
+
* step says so in one line (the caller keeps «next» shut) and offers the one
|
|
258
|
+
* way on: take them out, and book them as their own time.
|
|
189
259
|
*/
|
|
190
|
-
function NothingInThisParty({ person, labels, onRemove, }) {
|
|
260
|
+
function NothingInThisParty({ person, labels, multiLabels, onRemove, }) {
|
|
191
261
|
const values = { label: person.label };
|
|
192
|
-
|
|
262
|
+
let reason;
|
|
263
|
+
if (multiLabels)
|
|
264
|
+
reason = multiLabels['service.party.nothingBookable'];
|
|
265
|
+
else if (person.adult)
|
|
266
|
+
reason = labels['service.party.adultAlone'];
|
|
267
|
+
else
|
|
268
|
+
reason = labels['service.party.nothingFits'];
|
|
269
|
+
return (_jsxs("div", { className: "space-y-2 rounded-lg border border-dashed border-border px-5 py-4 text-sm", children: [_jsx("p", { children: renderLabel(reason, values) }), onRemove && (_jsx("button", { type: "button", onClick: onRemove, className: "font-semibold text-primary underline underline-offset-4", children: renderLabel(labels['service.party.remove'], values) }))] }));
|
|
193
270
|
}
|
|
194
271
|
/**
|
|
195
272
|
* One person with a «same as last time»: the suggestion as the answer, and the
|
|
@@ -197,10 +274,15 @@ function NothingInThisParty({ person, labels, onRemove, }) {
|
|
|
197
274
|
* by itself when the answer is something else already, so a choice is never
|
|
198
275
|
* hidden.
|
|
199
276
|
*/
|
|
200
|
-
function PersonWithSuggestion({ listLabel, suggestion, menu, chosen, onPick, choiceCard, unlikely, hasUnlikely, shared, }) {
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
|
|
277
|
+
function PersonWithSuggestion({ listLabel, suggestion, menu, ticked, chosen, onPick, choiceCard, unlikely, hasUnlikely, shared, }) {
|
|
278
|
+
const id = suggestion.service.id;
|
|
279
|
+
const [open, setOpen] = useState(ticked
|
|
280
|
+
? [...ticked].some((other) => other !== id)
|
|
281
|
+
: chosen !== null && chosen !== suggestion.service.id);
|
|
282
|
+
// Multi-select: the suggestion is in the menu too, so it can be unticked
|
|
283
|
+
// there, and something added to it.
|
|
284
|
+
const others = ticked ? menu : menu.filter((service) => service.id !== id);
|
|
285
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsx(SameAsLast, { suggestion: suggestion, selected: ticked ? ticked.has(id) : chosen === id, onPick: () => onPick(suggestion.service), shared: shared }), (others.length > 0 || hasUnlikely) && (_jsx("button", { type: "button", "aria-expanded": open, onClick: () => setOpen((current) => !current), className: "text-sm font-semibold text-primary underline underline-offset-4", children: shared.labels['service.chooseOther'] })), open && others.length > 0 && (_jsx("ul", { "aria-label": listLabel, className: "space-y-2", children: others.map((service) => (_jsx("li", { children: choiceCard(service) }, service.id))) })), open && unlikely] }));
|
|
204
286
|
}
|
|
205
287
|
/** «Same as last time · Kids' cut» — the person's last service, as one tap. */
|
|
206
288
|
function SameAsLast({ suggestion, selected, onPick, shared, }) {
|
|
@@ -220,12 +302,15 @@ function SameAsLast({ suggestion, selected, onPick, shared, }) {
|
|
|
220
302
|
* online, and a tap target that accepts the tap and does nothing is the dead
|
|
221
303
|
* end the phone number replaces. The card stays so the catalogue is whole.
|
|
222
304
|
*/
|
|
223
|
-
export function DefaultServiceCard({ service, kind, selected, onPick, phone, format, labels, className, selectedClassName, priceClassName, }) {
|
|
305
|
+
export function DefaultServiceCard({ service, kind, selected, multiple = false, onPick, phone, format, labels, className, selectedClassName, priceClassName, }) {
|
|
224
306
|
const duration = renderLabel(labels['service.duration'], { minutes: service.durationMinutes });
|
|
225
307
|
const price = (_jsx("span", { className: cn('whitespace-nowrap font-semibold text-foreground tabular-nums', priceClassName), children: format.price(service.priceOre) }));
|
|
226
308
|
if (kind === 'phone') {
|
|
227
309
|
return (_jsxs("div", { className: cn('flex min-h-14 w-full items-baseline justify-between gap-4 rounded-lg border border-dashed border-border px-5 py-4', className), children: [_jsx("span", { className: "font-medium", children: service.name }), _jsxs("span", { className: "flex items-baseline gap-4 text-right", children: [_jsx("span", { className: "text-sm text-muted-foreground tabular-nums", children: duration }), _jsxs("span", { className: "text-sm", children: [labels['service.phoneOnly'], phone ? (_jsxs(_Fragment, { children: [' – ', _jsx("a", { href: format.telHref(phone), "aria-label": fillLabel(labels['service.phoneOnlyLinkLabel'], { phone }), className: "font-semibold text-primary underline underline-offset-4", children: phone })] })) : null] })] })] }));
|
|
228
310
|
}
|
|
311
|
+
if (kind === 'choice' && multiple) {
|
|
312
|
+
return (_jsx(MultiServiceCard, { name: service.name, selected: selected, onPick: onPick, duration: duration, price: price, className: className, selectedClassName: selectedClassName }));
|
|
313
|
+
}
|
|
229
314
|
if (kind === 'choice') {
|
|
230
315
|
return (_jsxs("button", { type: "button", "aria-pressed": selected, onClick: onPick, className: cn('flex min-h-12 w-full items-baseline justify-between gap-4 rounded-lg border px-4 py-3 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', selected
|
|
231
316
|
? 'border-primary bg-primary/5 font-semibold ring-2 ring-primary'
|
|
@@ -5,9 +5,17 @@ import { useEffect, 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';
|
|
8
|
+
import { visitName } from './internal/visit.js';
|
|
8
9
|
import { fillLabel, labelText } from './labels.js';
|
|
9
10
|
import { LiveStatus } from './live-status.js';
|
|
10
11
|
import { slotClass } from './slots.js';
|
|
12
|
+
/**
|
|
13
|
+
* How many day chips sit under the heading.
|
|
14
|
+
*
|
|
15
|
+
* A thumb can scan about a week. The rest of the fetched window is picked
|
|
16
|
+
* from the month, not from a wrap of every open day.
|
|
17
|
+
*/
|
|
18
|
+
const DAY_STRIP_LIMIT = 7;
|
|
11
19
|
/**
|
|
12
20
|
* The time step — «when suits you?».
|
|
13
21
|
*
|
|
@@ -122,7 +130,8 @@ function partyChipLabel(labels, clock, slot, party) {
|
|
|
122
130
|
});
|
|
123
131
|
}
|
|
124
132
|
const seats = slot.seats.map((seat, index) => {
|
|
125
|
-
const
|
|
133
|
+
const item = party.items[index];
|
|
134
|
+
const who = item?.bookedForName ?? (item ? visitName(item) : '');
|
|
126
135
|
const stylist = party.resolveStylistName(seat.resourceId);
|
|
127
136
|
// No «with», rather than «with null»: a stylist the catalogue cannot name
|
|
128
137
|
// is a reload of the resource list, not a reason to hide the slot.
|
|
@@ -213,23 +222,36 @@ export function TimeScreen({ labels, format, dayparts, slots, days, openDays = n
|
|
|
213
222
|
? [...bookable, { startTs: currentSlotTs, resourceId: null }]
|
|
214
223
|
: bookable;
|
|
215
224
|
const partyByStart = new Map(party?.slots.map((slot) => [slot.startTs, slot]) ?? []);
|
|
216
|
-
const
|
|
217
|
-
// Read back through the
|
|
218
|
-
// availability cannot leave the step pointing at a day no longer
|
|
225
|
+
const offeredDays = buildDayStrip(clock, offered, days, takenSlotTs, currentSlotTs, openDays, now);
|
|
226
|
+
// Read back through the offered days rather than held as a day of its own,
|
|
227
|
+
// so new availability cannot leave the step pointing at a day no longer
|
|
228
|
+
// offered — including one chosen from the month, past the first week.
|
|
219
229
|
const [tappedDayKey, setTappedDayKey] = useState(null);
|
|
220
230
|
const [showMonth, setShowMonth] = useState(false);
|
|
221
231
|
const [monthTs, setMonthTs] = useState(null);
|
|
222
|
-
const selectedDay =
|
|
232
|
+
const selectedDay = offeredDays.find((day) => clock.dayKey(day) === tappedDayKey) ??
|
|
223
233
|
// A stolen slot lands the visitor back on the day they just lost.
|
|
224
234
|
(takenSlotTs === null
|
|
225
235
|
? undefined
|
|
226
|
-
:
|
|
236
|
+
: offeredDays.find((day) => clock.dayKey(day) === clock.dayKey(takenSlotTs))) ??
|
|
227
237
|
// Moving an appointment opens on the day it is on.
|
|
228
238
|
(currentSlotTs === null
|
|
229
239
|
? undefined
|
|
230
|
-
:
|
|
231
|
-
|
|
240
|
+
: offeredDays.find((day) => clock.dayKey(day) === clock.dayKey(currentSlotTs))) ??
|
|
241
|
+
offeredDays[0] ??
|
|
232
242
|
now;
|
|
243
|
+
// Without a month, every offered day stays a chip — there is nowhere else
|
|
244
|
+
// to reach it. With a month, the chips are the week that contains the day
|
|
245
|
+
// on screen, never the whole window.
|
|
246
|
+
const selectedIndex = offeredDays.findIndex((day) => clock.dayKey(day) === clock.dayKey(selectedDay));
|
|
247
|
+
// Fixed weeks of seven, counted from the first offered day. Starting the
|
|
248
|
+
// slice at the selected day would drop the previous chip on every tap.
|
|
249
|
+
const stripStart = !monthView || selectedIndex < 0
|
|
250
|
+
? 0
|
|
251
|
+
: Math.floor(selectedIndex / DAY_STRIP_LIMIT) * DAY_STRIP_LIMIT;
|
|
252
|
+
const strip = monthView
|
|
253
|
+
? offeredDays.slice(stripStart, stripStart + DAY_STRIP_LIMIT)
|
|
254
|
+
: offeredDays;
|
|
233
255
|
const selectedKey = clock.dayKey(selectedDay);
|
|
234
256
|
const daySlots = offered
|
|
235
257
|
.filter((slot) => clock.dayKey(slot.startTs) === selectedKey)
|
|
@@ -254,7 +276,7 @@ export function TimeScreen({ labels, format, dayparts, slots, days, openDays = n
|
|
|
254
276
|
return (_jsxs("section", { "aria-labelledby": "booking-time-heading", className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx(TimeHeading, { labels: labels, className: classNames?.heading }), strip.length > 1 && (_jsxs("fieldset", { className: slotClass(classNames, 'dayStrip', 'flex flex-wrap gap-2'), children: [_jsx("legend", { className: "sr-only", children: labels['time.dayStrip.legend'] }), strip.map((day) => {
|
|
255
277
|
const selected = clock.dayKey(day) === selectedKey;
|
|
256
278
|
return (_jsx(DayChip, { dayTs: day, label: clock.dayChip(day, now), selected: selected, onSelect: () => setTappedDayKey(clock.dayKey(day)), className: cn('rounded-full', selected ? 'border-primary bg-primary/10 font-bold ring-2 ring-primary' : '', classNames?.dayChip, selected ? classNames?.dayChipSelected : undefined) }, clock.dayKey(day)));
|
|
257
|
-
})] })), monthView && (_jsx(MonthCalendar, { labels: labels, clock: clock, open: showMonth, onToggle: () => setShowMonth((shown) => !shown), monthTs: monthTs ?? selectedDay, onMonth: setMonthTs, strip:
|
|
279
|
+
})] })), monthView && (_jsx(MonthCalendar, { labels: labels, clock: clock, open: showMonth, onToggle: () => setShowMonth((shown) => !shown), monthTs: monthTs ?? selectedDay, onMonth: setMonthTs, strip: offeredDays, selectedDay: selectedDay, now: now, onPickDay: (day) => setTappedDayKey(clock.dayKey(day)), classNames: classNames })), surcharge !== null && (_jsx("p", { "data-testid": "surcharge-row", className: slotClass(classNames, 'surcharge', 'min-h-5 text-sm text-muted-foreground'), children: clock.isWeekend(selectedDay) ? weekendSentence(labels, format, surcharge) : null })), dayBookable.length === 0 && alternative && party && (_jsx(ParallelInstead, { labels: labels, clock: clock, slot: alternative, day: selectedDay, now: now, size: party.items.length, onAccept: () => party.onPick(alternative), className: classNames?.parallel })), dayBookable.length === 0 && !(alternative && party) && (_jsx(NothingFreeCard, { labels: labels, format: format, day: selectedDay, now: now, phone: phone, standing: dayStanding(clock, selectedDay, openDays, now), stylistName: stylistName, className: classNames?.empty })), dayBookable.length > 0 && (_jsx(DaypartSections, { labels: labels, clock: clock, dayparts: dayparts, daySlots: daySlots, nearest: nearest, currentSlotTs: currentSlotTs, party: party, partyByStart: partyByStart, onPick: onPick, classNames: classNames, TimeChip: components?.TimeChip ?? DefaultTimeChip })), elsewhere && (_jsx(BookingButton, { variant: "link", onClick: () => setTappedDayKey(clock.dayKey(elsewhere.startTs)), className: classNames?.nextFree, children: labels['time.nextFree'] })), isToday && dayBookable.length > 0 && (_jsx(ShortNoticeLine, { labels: labels, format: format, phone: phone, className: classNames?.shortNotice }))] }));
|
|
258
280
|
}
|
|
259
281
|
function TimeHeading({ labels, className, }) {
|
|
260
282
|
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'] }));
|
|
@@ -301,16 +323,17 @@ export function TimeScreenSkeleton({ labels, days = 7, monthView = false, surcha
|
|
|
301
323
|
size: 'lg',
|
|
302
324
|
className: 'min-w-20 animate-pulse border-border bg-muted text-transparent',
|
|
303
325
|
});
|
|
304
|
-
return (_jsxs("section", { "aria-labelledby": "booking-time-heading", className: cn('space-y-6', className), children: [_jsx(TimeHeading, { labels: labels }), _jsx(LiveStatus, { text: labelText(labels['time.loading']) }), _jsxs("div", { "aria-hidden": "true", className: "space-y-6", children: [_jsx("div", { className: "flex flex-wrap gap-2", children: Array.from({ length: days }, (_, index) => (_jsx("span", { "data-testid": "day-chip-skeleton", className: dayChip, children: labels['time.skeleton.dayPlaceholder'] }, index))) }), monthView && _jsx("span", { className: "block h-8 w-36 animate-pulse rounded bg-muted" }), surchargeRow && _jsx("span", { "data-testid": "surcharge-row-skeleton", className: "block min-h-5" }), _jsxs("div", { className: "space-y-3", children: [_jsx("span", { className: "block h-7 w-32 animate-pulse rounded bg-muted" }), _jsx("div", { className: "flex flex-wrap gap-2", children: Array.from({ length: 8 }, (_, index) => (_jsx("span", { "data-testid": "slot-skeleton", className: slotChip, children: "00:00" }, index))) })] })] })] }));
|
|
326
|
+
return (_jsxs("section", { "aria-labelledby": "booking-time-heading", className: cn('space-y-6', className), children: [_jsx(TimeHeading, { labels: labels }), _jsx(LiveStatus, { text: labelText(labels['time.loading']) }), _jsxs("div", { "aria-hidden": "true", className: "space-y-6", children: [_jsx("div", { className: "flex flex-wrap gap-2", children: Array.from({ length: monthView ? Math.min(days, DAY_STRIP_LIMIT) : days }, (_, index) => (_jsx("span", { "data-testid": "day-chip-skeleton", className: dayChip, children: labels['time.skeleton.dayPlaceholder'] }, index))) }), monthView && _jsx("span", { className: "block h-8 w-36 animate-pulse rounded bg-muted" }), surchargeRow && _jsx("span", { "data-testid": "surcharge-row-skeleton", className: "block min-h-5" }), _jsxs("div", { className: "space-y-3", children: [_jsx("span", { className: "block h-7 w-32 animate-pulse rounded bg-muted" }), _jsx("div", { className: "flex flex-wrap gap-2", children: Array.from({ length: 8 }, (_, index) => (_jsx("span", { "data-testid": "slot-skeleton", className: slotChip, children: "00:00" }, index))) })] })] })] }));
|
|
305
327
|
}
|
|
306
328
|
// ---------------------------------------------------------------------------
|
|
307
329
|
// Month view
|
|
308
330
|
// ---------------------------------------------------------------------------
|
|
309
331
|
/**
|
|
310
|
-
* «Show the whole month» — the
|
|
311
|
-
* Only
|
|
312
|
-
* that let a visitor tap an unasked-about date and then
|
|
313
|
-
* inventing a fact. Collapsed by default; navigable both
|
|
332
|
+
* «Show the whole month» — the fetched window laid out as the month it sits in.
|
|
333
|
+
* Only days the caller asked about are tappable; every other cell is inert,
|
|
334
|
+
* because a calendar that let a visitor tap an unasked-about date and then
|
|
335
|
+
* said «full» would be inventing a fact. Collapsed by default; navigable both
|
|
336
|
+
* ways, never disabled.
|
|
314
337
|
*/
|
|
315
338
|
function MonthCalendar({ labels, clock, open, onToggle, monthTs, onMonth, strip, selectedDay, now, onPickDay, classNames, }) {
|
|
316
339
|
const offered = new Map(strip.map((day) => [clock.dayKey(day), day]));
|
package/dist/booking/types.d.ts
CHANGED
|
@@ -70,9 +70,15 @@ export interface WizardPerson {
|
|
|
70
70
|
birthYear?: number;
|
|
71
71
|
birthMonth?: number;
|
|
72
72
|
}
|
|
73
|
-
/** One line of the basket. */
|
|
73
|
+
/** One line of the basket: one person's visit. */
|
|
74
74
|
export interface WizardItem {
|
|
75
|
+
/** The person's FIRST service (the only one, when `extraServices` is absent). */
|
|
75
76
|
service: WizardService;
|
|
77
|
+
/**
|
|
78
|
+
* The services after the first, in order, done back to back in the same
|
|
79
|
+
* visit with the same stylist. ABSENT for a one-service visit — never `[]`.
|
|
80
|
+
*/
|
|
81
|
+
extraServices?: WizardService[];
|
|
76
82
|
bookedForName?: string;
|
|
77
83
|
bookedForBirthYear?: number;
|
|
78
84
|
bookedForPersonId?: string;
|
|
@@ -84,7 +90,14 @@ export type PartyMode = 'sequential' | 'parallel';
|
|
|
84
90
|
export interface WizardState {
|
|
85
91
|
step: WizardStep;
|
|
86
92
|
people: WizardPerson[];
|
|
93
|
+
/** Each person's FIRST service so far, `null` for none yet. */
|
|
87
94
|
choices: Array<WizardService | null>;
|
|
95
|
+
/**
|
|
96
|
+
* Each person's services after the first, parallel to `people` (`extras[i]`
|
|
97
|
+
* is `people[i]`'s). Absent on a machine without multi-service visits: every
|
|
98
|
+
* person then has one service.
|
|
99
|
+
*/
|
|
100
|
+
extras?: WizardService[][];
|
|
88
101
|
items: WizardItem[];
|
|
89
102
|
resourceId: string | null;
|
|
90
103
|
stylistAnswered: boolean;
|
|
@@ -145,7 +145,7 @@ function GuestChips({ choices, people, labels, classNames, onChoose, }) {
|
|
|
145
145
|
}) }));
|
|
146
146
|
}
|
|
147
147
|
function CheckCircle({ checked }) {
|
|
148
|
-
return (_jsx("span", { "aria-hidden": "true", className: cn('flex size-6 shrink-0 items-center justify-center rounded-full border', checked ? 'border-primary bg-primary text-primary-foreground' : 'border-
|
|
148
|
+
return (_jsx("span", { "aria-hidden": "true", className: cn('flex size-6 shrink-0 items-center justify-center rounded-full border', checked ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground'), children: checked && _jsx(Check, { className: "size-4" }) }));
|
|
149
149
|
}
|
|
150
150
|
/** The default person card: an initial on a tinted avatar, the name, a line, a tick. */
|
|
151
151
|
export function DefaultPersonCard({ name, line, index, selected, disabled, describedBy, onToggle, className, selectedClassName, }) {
|