@medalsocial/meda 3.3.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -1
- package/dist/booking/add-child-sheet.d.ts +2 -1
- package/dist/booking/add-child-sheet.js +2 -1
- package/dist/booking/booking-skeleton.d.ts +2 -1
- package/dist/booking/confirmation.d.ts +2 -1
- package/dist/booking/confirmation.js +7 -6
- package/dist/booking/details-screen.d.ts +5 -1
- package/dist/booking/details-screen.js +13 -8
- package/dist/booking/index.d.ts +2 -2
- package/dist/booking/index.js +2 -2
- package/dist/booking/internal/label-parts.d.ts +36 -21
- package/dist/booking/internal/label-parts.js +71 -43
- package/dist/booking/internal/service-multi.d.ts +55 -0
- package/dist/booking/internal/service-multi.js +83 -0
- package/dist/booking/internal/visit.d.ts +13 -0
- package/dist/booking/internal/visit.js +10 -0
- package/dist/booking/label-keys.d.ts +13 -5
- package/dist/booking/label-keys.js +2 -1
- package/dist/booking/labels.d.ts +37 -8
- package/dist/booking/labels.js +45 -8
- package/dist/booking/login-panel.d.ts +3 -2
- package/dist/booking/login-panel.js +3 -2
- package/dist/booking/login-sheet.d.ts +2 -1
- package/dist/booking/login-sheet.js +2 -1
- package/dist/booking/manage-screen.d.ts +2 -1
- package/dist/booking/manage-screen.js +16 -11
- package/dist/booking/otp-slots.d.ts +2 -1
- package/dist/booking/otp-slots.js +2 -1
- package/dist/booking/portal/age-confirm-card.d.ts +2 -1
- package/dist/booking/portal/child-cards.d.ts +2 -1
- package/dist/booking/portal/data-controls.d.ts +2 -1
- package/dist/booking/portal/data-controls.js +3 -3
- package/dist/booking/portal/family-editor.d.ts +2 -1
- package/dist/booking/portal/family-editor.js +9 -9
- package/dist/booking/portal/logout-button.d.ts +2 -1
- package/dist/booking/portal/logout-button.js +2 -1
- package/dist/booking/portal/portal-shell.d.ts +2 -1
- package/dist/booking/portal/portal-shell.js +3 -2
- package/dist/booking/portal/portal-unreachable.d.ts +2 -1
- package/dist/booking/portal/profile-form.d.ts +2 -1
- package/dist/booking/portal/profile-form.js +3 -2
- package/dist/booking/portal/rebook-cards.d.ts +2 -1
- package/dist/booking/portal/rebook-cards.js +3 -2
- package/dist/booking/portal/upcoming-bookings.d.ts +2 -1
- package/dist/booking/portal/upcoming-bookings.js +3 -3
- package/dist/booking/portal/vipps-link-row.d.ts +2 -1
- package/dist/booking/portal/vipps-link-row.js +7 -6
- package/dist/booking/portal/visit-history.d.ts +2 -1
- package/dist/booking/service-screen.d.ts +78 -3
- package/dist/booking/service-screen.js +131 -46
- package/dist/booking/stylist-screen.d.ts +2 -1
- package/dist/booking/stylist-screen.js +7 -6
- package/dist/booking/summary-bar.d.ts +2 -1
- package/dist/booking/time-screen.d.ts +2 -1
- package/dist/booking/time-screen.js +42 -19
- package/dist/booking/types.d.ts +14 -1
- package/dist/booking/vipps-button.d.ts +2 -1
- package/dist/booking/vipps-button.js +2 -1
- package/dist/booking/who-screen.d.ts +2 -1
- package/dist/booking/who-screen.js +4 -3
- package/package.json +1 -1
- package/skills/components/SKILL.md +4 -0
|
@@ -3,8 +3,9 @@ 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
|
-
import { fillLabel } from './labels.js';
|
|
8
|
+
import { fillLabel, labelText } from './labels.js';
|
|
8
9
|
import { slotClass } from './slots.js';
|
|
9
10
|
/**
|
|
10
11
|
* The service step — what should be done?
|
|
@@ -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,16 +33,41 @@ 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`. */
|
|
38
68
|
function ageDividerLabel(labels, name, possessive) {
|
|
39
69
|
if (!name)
|
|
40
|
-
return labels['service.ageDivider.unnamed'];
|
|
70
|
+
return labelText(labels['service.ageDivider.unnamed']);
|
|
41
71
|
return fillLabel(labels['service.ageDivider.named'], {
|
|
42
72
|
name,
|
|
43
73
|
nameGenitive: possessive(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'
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ComponentType, type Ref } from 'react';
|
|
2
2
|
import type { BookingFormat } from './format.js';
|
|
3
|
+
import { type BookingLabel } from './labels.js';
|
|
3
4
|
import { type SlotClassNames } from './slots.js';
|
|
4
5
|
import type { BookingResourceDto, PartyMode } from './types.js';
|
|
5
6
|
/**
|
|
@@ -20,7 +21,7 @@ import type { BookingResourceDto, PartyMode } from './types.js';
|
|
|
20
21
|
* available» included. Arrow keys move and choose, Tab enters and leaves once.
|
|
21
22
|
*/
|
|
22
23
|
export declare const STYLIST_SCREEN_LABEL_KEYS: readonly ["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"];
|
|
23
|
-
export type StylistScreenLabels = Record<(typeof STYLIST_SCREEN_LABEL_KEYS)[number],
|
|
24
|
+
export type StylistScreenLabels = Record<(typeof STYLIST_SCREEN_LABEL_KEYS)[number], BookingLabel>;
|
|
24
25
|
/**
|
|
25
26
|
* - `root` — the `<section>`
|
|
26
27
|
* - `heading` — the `<h2>`
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useEffect, useId, useRef, } from 'react';
|
|
4
4
|
import { cn } from '../lib/utils.js';
|
|
5
5
|
import { renderLabel } from './internal/label-parts.js';
|
|
6
|
+
import { labelText } from './labels.js';
|
|
6
7
|
import { LiveStatus } from './live-status.js';
|
|
7
8
|
import { slotClass } from './slots.js';
|
|
8
9
|
/**
|
|
@@ -83,10 +84,10 @@ export function StylistScreen({ labels, format, now, serviceIds, resources, sele
|
|
|
83
84
|
const options = [
|
|
84
85
|
{
|
|
85
86
|
resourceId: null,
|
|
86
|
-
name: labels['stylist.firstAvailable'],
|
|
87
|
-
shortName: labels['stylist.firstAvailable'],
|
|
88
|
-
subtitle: labels['stylist.firstAvailableSubtitle'],
|
|
89
|
-
badge: labels['stylist.firstAvailableBadge'],
|
|
87
|
+
name: labelText(labels['stylist.firstAvailable']),
|
|
88
|
+
shortName: labelText(labels['stylist.firstAvailable']),
|
|
89
|
+
subtitle: labelText(labels['stylist.firstAvailableSubtitle']),
|
|
90
|
+
badge: labelText(labels['stylist.firstAvailableBadge']),
|
|
90
91
|
},
|
|
91
92
|
...(loading
|
|
92
93
|
? held
|
|
@@ -113,7 +114,7 @@ export function StylistScreen({ labels, format, now, serviceIds, resources, sele
|
|
|
113
114
|
}), classNames: classNames })] })), party?.mode === 'parallel' ? (_jsx("p", { className: "rounded-lg border border-border bg-card px-5 py-4 text-sm", children: bySize(labels, 'stylist.party.parallelNote', party.size, party.sizeWord) })) : (_jsx(StylistRadioGroup, { options: options, selectedResourceId: selectedResourceId,
|
|
114
115
|
// The held option takes one placeholder's place, so the row is
|
|
115
116
|
// exactly as long as it was and nothing after it moves.
|
|
116
|
-
skeletons: loading ? Math.max(0, skeletonCount - (options.length - 1)) : 0, busy: loading, onPick: onPick, labels: labels, classNames: classNames, Card: components?.StylistCard ?? DefaultStylistCard })), _jsx(LiveStatus, { text: loading ? labels['stylist.loading'] : notice })] }));
|
|
117
|
+
skeletons: loading ? Math.max(0, skeletonCount - (options.length - 1)) : 0, busy: loading, onPick: onPick, labels: labels, classNames: classNames, Card: components?.StylistCard ?? DefaultStylistCard })), _jsx(LiveStatus, { text: loading ? labelText(labels['stylist.loading']) : notice })] }));
|
|
117
118
|
}
|
|
118
119
|
/**
|
|
119
120
|
* The options as one radiogroup with a roving tab stop.
|
|
@@ -183,7 +184,7 @@ function StylistRadioGroup({ options, selectedResourceId, skeletons, busy, onPic
|
|
|
183
184
|
* with its next-opening row pulsing like every card still waiting for one. */
|
|
184
185
|
function pendingOption(resourceId, pendingName, labels, format) {
|
|
185
186
|
const known = pendingName?.trim() ? pendingName.trim() : null;
|
|
186
|
-
const name = known ?? labels['stylist.pendingName'];
|
|
187
|
+
const name = known ?? labelText(labels['stylist.pendingName']);
|
|
187
188
|
return {
|
|
188
189
|
resourceId,
|
|
189
190
|
name,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BookingLabel } from './labels.js';
|
|
1
2
|
import { type SlotClassNames } from './slots.js';
|
|
2
3
|
import type { WizardStep } from './types.js';
|
|
3
4
|
/**
|
|
@@ -9,7 +10,7 @@ import type { WizardStep } from './types.js';
|
|
|
9
10
|
* wizard it summarises.
|
|
10
11
|
*/
|
|
11
12
|
export declare const SUMMARY_BAR_LABEL_KEYS: readonly ["summary.placeholder.who", "summary.placeholder.service", "summary.next"];
|
|
12
|
-
export type SummaryBarLabels = Record<(typeof SUMMARY_BAR_LABEL_KEYS)[number],
|
|
13
|
+
export type SummaryBarLabels = Record<(typeof SUMMARY_BAR_LABEL_KEYS)[number], BookingLabel>;
|
|
13
14
|
/** `root` the bar, `line` the summary sentence, `next` the button. */
|
|
14
15
|
export type SummaryBarSlot = 'root' | 'line' | 'next';
|
|
15
16
|
export interface SummaryBarProps {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ComponentType } from 'react';
|
|
2
2
|
import type { BookingDaypart, BookingFormat } from './format.js';
|
|
3
|
+
import { type BookingLabel } from './labels.js';
|
|
3
4
|
import { type SlotClassNames } from './slots.js';
|
|
4
5
|
import type { BookingDayDto, BookingSlotDto, PartySlot, WizardItem } from './types.js';
|
|
5
6
|
/**
|
|
@@ -26,7 +27,7 @@ export declare const TIME_SCREEN_LABEL_KEYS: readonly ["time.heading", "time.day
|
|
|
26
27
|
* finishes (end them with the dash); `closed` and `over` are whole sentences.
|
|
27
28
|
* - `time.empty.callLink|callLinkAria` `{phone}`
|
|
28
29
|
*/
|
|
29
|
-
export type TimeScreenLabels = Record<(typeof TIME_SCREEN_LABEL_KEYS)[number],
|
|
30
|
+
export type TimeScreenLabels = Record<(typeof TIME_SCREEN_LABEL_KEYS)[number], BookingLabel>;
|
|
30
31
|
/**
|
|
31
32
|
* `classNames` slots:
|
|
32
33
|
* - `root` the section · `heading` the h2
|
|
@@ -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 {
|
|
8
|
+
import { visitName } from './internal/visit.js';
|
|
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.
|
|
@@ -130,7 +139,7 @@ function partyChipLabel(labels, clock, slot, party) {
|
|
|
130
139
|
});
|
|
131
140
|
return fillLabel(labels['time.party.parallel'], {
|
|
132
141
|
time: clock.formatTime(slot.startTs),
|
|
133
|
-
seats: seats.join(labels['time.party.seatSeparator']),
|
|
142
|
+
seats: seats.join(labelText(labels['time.party.seatSeparator'])),
|
|
134
143
|
});
|
|
135
144
|
}
|
|
136
145
|
/**
|
|
@@ -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'] }));
|
|
@@ -283,7 +305,7 @@ export function TakenToast({ labels, takenSlotTs, className }) {
|
|
|
283
305
|
}, [takenSlotTs]);
|
|
284
306
|
return (_jsx("div", { role: "status", "aria-live": "polite", className: shown
|
|
285
307
|
? cn('fixed inset-x-4 bottom-24 z-50 mx-auto flex max-w-md items-center gap-3 rounded-lg border border-secondary/40 bg-background px-5 py-3 text-sm shadow-lg', className)
|
|
286
|
-
: 'sr-only', children: shown && (_jsxs(_Fragment, { children: [_jsx("p", { className: "flex-1", children: labels['time.taken.message'] }), _jsx("button", { type: "button", "aria-label": labels['time.taken.close'], onClick: () => setShown(false), className: "flex size-8 shrink-0 items-center justify-center rounded-full hover:bg-muted", children: _jsx(X, { "aria-hidden": "true", className: "size-4" }) })] })) }));
|
|
308
|
+
: 'sr-only', children: shown && (_jsxs(_Fragment, { children: [_jsx("p", { className: "flex-1", children: labels['time.taken.message'] }), _jsx("button", { type: "button", "aria-label": labelText(labels['time.taken.close']), onClick: () => setShown(false), className: "flex size-8 shrink-0 items-center justify-center rounded-full hover:bg-muted", children: _jsx(X, { "aria-hidden": "true", className: "size-4" }) })] })) }));
|
|
287
309
|
}
|
|
288
310
|
/**
|
|
289
311
|
* The time step before its openings are in: the same heading, a strip of day
|
|
@@ -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: 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]));
|
|
@@ -323,7 +346,7 @@ function MonthCalendar({ labels, clock, open, onToggle, monthTs, onMonth, strip,
|
|
|
323
346
|
year,
|
|
324
347
|
});
|
|
325
348
|
const selectedKey = clock.dayKey(selectedDay);
|
|
326
|
-
return (_jsxs("div", { className: slotClass(classNames, 'month', 'space-y-3'), children: [_jsxs(BookingButton, { variant: "link", size: "sm", "aria-expanded": open, onClick: onToggle, className: "px-0", children: [open ? labels['time.month.hide'] : labels['time.month.show'], open ? (_jsx(ChevronUp, { "aria-hidden": "true", className: "size-4" })) : (_jsx(ChevronDown, { "aria-hidden": "true", className: "size-4" }))] }), open && (_jsxs("div", { className: "space-y-3 rounded-lg border border-border bg-card px-4 py-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx(BookingButton, { variant: "outline", size: "icon", "aria-label": labels['time.month.previous'], onClick: () => onMonth(clock.dayStart(firstOfMonth, -1)), className: "rounded-full", children: _jsx(ChevronLeft, { "aria-hidden": "true", className: "size-4" }) }), _jsx("p", { "aria-live": "polite", className: "font-sans font-bold", children: heading }), _jsx(BookingButton, { variant: "outline", size: "icon", "aria-label": labels['time.month.next'], onClick: () => onMonth(clock.dayStart(firstOfMonth, daysInMonth(year, month))), className: "rounded-full", children: _jsx(ChevronRight, { "aria-hidden": "true", className: "size-4" }) })] }), _jsx("div", { "aria-hidden": "true", className: "grid grid-cols-7 gap-1 text-center", children: clock.weekdayHeads().map((head, index) => (_jsx("span", { className: "text-xs font-bold text-muted-foreground", children: head }, index))) }), _jsx("div", { className: "grid grid-cols-7 gap-1", children: cells.map((cell, index) => {
|
|
349
|
+
return (_jsxs("div", { className: slotClass(classNames, 'month', 'space-y-3'), children: [_jsxs(BookingButton, { variant: "link", size: "sm", "aria-expanded": open, onClick: onToggle, className: "px-0", children: [open ? labels['time.month.hide'] : labels['time.month.show'], open ? (_jsx(ChevronUp, { "aria-hidden": "true", className: "size-4" })) : (_jsx(ChevronDown, { "aria-hidden": "true", className: "size-4" }))] }), open && (_jsxs("div", { className: "space-y-3 rounded-lg border border-border bg-card px-4 py-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx(BookingButton, { variant: "outline", size: "icon", "aria-label": labelText(labels['time.month.previous']), onClick: () => onMonth(clock.dayStart(firstOfMonth, -1)), className: "rounded-full", children: _jsx(ChevronLeft, { "aria-hidden": "true", className: "size-4" }) }), _jsx("p", { "aria-live": "polite", className: "font-sans font-bold", children: heading }), _jsx(BookingButton, { variant: "outline", size: "icon", "aria-label": labelText(labels['time.month.next']), onClick: () => onMonth(clock.dayStart(firstOfMonth, daysInMonth(year, month))), className: "rounded-full", children: _jsx(ChevronRight, { "aria-hidden": "true", className: "size-4" }) })] }), _jsx("div", { "aria-hidden": "true", className: "grid grid-cols-7 gap-1 text-center", children: clock.weekdayHeads().map((head, index) => (_jsx("span", { className: "text-xs font-bold text-muted-foreground", children: head }, index))) }), _jsx("div", { className: "grid grid-cols-7 gap-1", children: cells.map((cell, index) => {
|
|
327
350
|
if (cell === null) {
|
|
328
351
|
// The blanks before the first of the month; the position is
|
|
329
352
|
// the only thing that tells them apart.
|
|
@@ -366,7 +389,7 @@ function DaypartSections({ labels, clock, dayparts, daySlots, nearest, currentSl
|
|
|
366
389
|
else if (partySlot && party) {
|
|
367
390
|
label = partyChipLabel(labels, clock, partySlot, party);
|
|
368
391
|
}
|
|
369
|
-
return (_jsx(TimeChip, { startTs: slot.startTs, label: label, nearest: isNearest, nearestLabel: labels['time.nearest'], current: current, onPick: () => (partySlot && party ? party.onPick(partySlot) : onPick(slot)), className: cn('min-w-20 tabular-nums', isNearest ? 'border-primary ring-2 ring-primary' : '', classNames?.chip, isNearest ? classNames?.chipNearest : undefined, current ? classNames?.chipCurrent : undefined) }, slot.startTs));
|
|
392
|
+
return (_jsx(TimeChip, { startTs: slot.startTs, label: label, nearest: isNearest, nearestLabel: labelText(labels['time.nearest']), current: current, onPick: () => (partySlot && party ? party.onPick(partySlot) : onPick(slot)), className: cn('min-w-20 tabular-nums', isNearest ? 'border-primary ring-2 ring-primary' : '', classNames?.chip, isNearest ? classNames?.chipNearest : undefined, current ? classNames?.chipCurrent : undefined) }, slot.startTs));
|
|
370
393
|
}) })] }, daypart.key));
|
|
371
394
|
});
|
|
372
395
|
}
|
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;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { type BookingLabel } from './labels.js';
|
|
1
2
|
import { type SlotClassNames } from './slots.js';
|
|
2
3
|
export declare const VIPPS_BUTTON_LABEL_KEYS: readonly ["vipps.button", "vipps.unavailable", "vipps.throttled"];
|
|
3
|
-
export type VippsButtonLabels = Record<(typeof VIPPS_BUTTON_LABEL_KEYS)[number],
|
|
4
|
+
export type VippsButtonLabels = Record<(typeof VIPPS_BUTTON_LABEL_KEYS)[number], BookingLabel>;
|
|
4
5
|
/** Why a Vipps start did NOT end in a redirect. `null` is silence. */
|
|
5
6
|
export type VippsStartState = {
|
|
6
7
|
ok: false;
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useActionState, useId } from 'react';
|
|
4
4
|
import { useFormStatus } from 'react-dom';
|
|
5
5
|
import { bookingButtonClass } from './internal/ui.js';
|
|
6
|
+
import { labelText } from './labels.js';
|
|
6
7
|
import { slotClass } from './slots.js';
|
|
7
8
|
export const VIPPS_BUTTON_LABEL_KEYS = [
|
|
8
9
|
'vipps.button',
|
|
@@ -26,7 +27,7 @@ export function VippsButton({ onVipps, next = null, label, labels, classNames })
|
|
|
26
27
|
const [state, formAction] = useActionState(onVipps, null);
|
|
27
28
|
const noticeId = `${useId()}-notice`;
|
|
28
29
|
const notice = state === null ? null : labels[`vipps.${state.reason}`];
|
|
29
|
-
return (_jsxs("form", { action: formAction, className: slotClass(classNames, 'root', 'space-y-3'), "aria-describedby": notice ? noticeId : undefined, children: [next && _jsx("input", { type: "hidden", name: "next", value: next }), _jsx(SubmitButton, { label: label ?? labels['vipps.button'], className: bookingButtonClass({
|
|
30
|
+
return (_jsxs("form", { action: formAction, className: slotClass(classNames, 'root', 'space-y-3'), "aria-describedby": notice ? noticeId : undefined, children: [next && _jsx("input", { type: "hidden", name: "next", value: next }), _jsx(SubmitButton, { label: label ?? labelText(labels['vipps.button']), className: bookingButtonClass({
|
|
30
31
|
size: 'lg',
|
|
31
32
|
className: slotClass(classNames, 'button', 'w-full rounded-full bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring/40'),
|
|
32
33
|
}) }), _jsx("div", { "aria-live": "polite", children: notice && (_jsx("p", { id: noticeId, className: slotClass(classNames, 'notice', 'text-sm text-muted-foreground'), children: notice })) })] }));
|