@medalsocial/meda 3.10.0 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/booking/booking-panel.d.ts +65 -0
- package/dist/booking/booking-panel.js +76 -0
- package/dist/booking/confirmation.d.ts +28 -2
- package/dist/booking/confirmation.js +111 -2
- package/dist/booking/index.d.ts +1 -0
- package/dist/booking/index.js +1 -0
- package/dist/booking/label-keys.d.ts +5 -4
- package/dist/booking/label-keys.js +6 -2
- package/dist/booking/service-screen.d.ts +14 -1
- package/dist/booking/service-screen.js +61 -11
- package/dist/booking/summary-bar.js +2 -4
- package/dist/booking/who-screen.d.ts +10 -1
- package/dist/booking/who-screen.js +41 -11
- package/package.json +1 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { BookingFormat } from './format.js';
|
|
3
|
+
import { type BookingLabel } from './labels.js';
|
|
4
|
+
import { type SlotClassNames } from './slots.js';
|
|
5
|
+
/**
|
|
6
|
+
* «Your appointment» — the desktop's sticky side panel: everything chosen so
|
|
7
|
+
* far, the price, and the way on, in the half of a wide window a single
|
|
8
|
+
* column leaves empty.
|
|
9
|
+
*
|
|
10
|
+
* Presentational, like the summary bar it replaces from `lg` up: the rows,
|
|
11
|
+
* the total and whether «continue» does anything come in precomputed from the
|
|
12
|
+
* caller's machine. A row with no answer yet says so in muted text rather
|
|
13
|
+
* than disappearing, so the panel keeps its shape while it fills in.
|
|
14
|
+
*/
|
|
15
|
+
export declare const BOOKING_PANEL_LABEL_KEYS: readonly ["panel.heading", "panel.who", "panel.service", "panel.time", "panel.stylist", "panel.notChosen", "panel.firstAvailable", "panel.alsoFree", "panel.swap", "panel.swapTo", "panel.total", "panel.continue"];
|
|
16
|
+
export type BookingPanelLabels = Record<(typeof BOOKING_PANEL_LABEL_KEYS)[number], BookingLabel>;
|
|
17
|
+
export interface BookingPanelStylist {
|
|
18
|
+
name: string;
|
|
19
|
+
photoUrl?: string | null;
|
|
20
|
+
/** «First available» chose them. */
|
|
21
|
+
firstAvailable?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface BookingPanelAlternative {
|
|
24
|
+
resourceId: string;
|
|
25
|
+
name: string;
|
|
26
|
+
photoUrl?: string | null;
|
|
27
|
+
}
|
|
28
|
+
/** The small print under the button: the change rule, how to pay, where. */
|
|
29
|
+
export interface BookingPanelNote {
|
|
30
|
+
kind: 'change' | 'payment' | 'place';
|
|
31
|
+
text: ReactNode;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* - `root` the `<aside>`
|
|
35
|
+
* - `heading` the `<h2>`
|
|
36
|
+
* - `total` the total row
|
|
37
|
+
* - `continue` the button
|
|
38
|
+
*/
|
|
39
|
+
export type BookingPanelSlot = 'root' | 'heading' | 'total' | 'continue';
|
|
40
|
+
export interface BookingPanelProps {
|
|
41
|
+
labels: BookingPanelLabels;
|
|
42
|
+
format: BookingFormat;
|
|
43
|
+
/** «1 child, 1 adult»; `null` while nobody is chosen. */
|
|
44
|
+
who: string | null;
|
|
45
|
+
/** «Boys' cut · 30 min»; `null` while nothing is chosen. */
|
|
46
|
+
services: string | null;
|
|
47
|
+
/** The chosen start; `null` before a time is picked. */
|
|
48
|
+
startTs: number | null;
|
|
49
|
+
/**
|
|
50
|
+
* `null` before one is chosen; `undefined` draws no row at all (a family
|
|
51
|
+
* seen at once has a stylist per person, which the time chips already name).
|
|
52
|
+
*/
|
|
53
|
+
stylist?: BookingPanelStylist | null;
|
|
54
|
+
/** Others free at the same start. Drawn with `onSwap`; at most two. */
|
|
55
|
+
alternatives?: readonly BookingPanelAlternative[];
|
|
56
|
+
onSwap?: (resourceId: string) => void;
|
|
57
|
+
/** The visit's total in minor units; `null` draws no total. */
|
|
58
|
+
totalOre: number | null;
|
|
59
|
+
/** Absent: no button (a step with its own, like the details form). */
|
|
60
|
+
onContinue?: () => void;
|
|
61
|
+
canContinue?: boolean;
|
|
62
|
+
notes?: readonly BookingPanelNote[];
|
|
63
|
+
classNames?: SlotClassNames<BookingPanelSlot>;
|
|
64
|
+
}
|
|
65
|
+
export declare function BookingPanel({ labels, format, who, services, startTs, stylist, alternatives, onSwap, totalOre, onContinue, canContinue, notes, classNames, }: BookingPanelProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ArrowLeftRight, CreditCard, MapPin } from 'lucide-react';
|
|
4
|
+
import { cn } from '../lib/utils.js';
|
|
5
|
+
import { renderLabel } from './internal/label-parts.js';
|
|
6
|
+
import { BookingButton } from './internal/ui.js';
|
|
7
|
+
import { fillLabel } from './labels.js';
|
|
8
|
+
import { slotClass } from './slots.js';
|
|
9
|
+
/**
|
|
10
|
+
* «Your appointment» — the desktop's sticky side panel: everything chosen so
|
|
11
|
+
* far, the price, and the way on, in the half of a wide window a single
|
|
12
|
+
* column leaves empty.
|
|
13
|
+
*
|
|
14
|
+
* Presentational, like the summary bar it replaces from `lg` up: the rows,
|
|
15
|
+
* the total and whether «continue» does anything come in precomputed from the
|
|
16
|
+
* caller's machine. A row with no answer yet says so in muted text rather
|
|
17
|
+
* than disappearing, so the panel keeps its shape while it fills in.
|
|
18
|
+
*/
|
|
19
|
+
export const BOOKING_PANEL_LABEL_KEYS = [
|
|
20
|
+
/** The panel's heading, «Your appointment». */
|
|
21
|
+
'panel.heading',
|
|
22
|
+
/** Row names. */
|
|
23
|
+
'panel.who',
|
|
24
|
+
'panel.service',
|
|
25
|
+
'panel.time',
|
|
26
|
+
'panel.stylist',
|
|
27
|
+
/** A row with nothing chosen yet, «Not chosen». */
|
|
28
|
+
'panel.notChosen',
|
|
29
|
+
/** After the stylist's name when «first available» picked them, «first available». */
|
|
30
|
+
'panel.firstAvailable',
|
|
31
|
+
/** Leads the swap row, «Also free then:». */
|
|
32
|
+
'panel.alsoFree',
|
|
33
|
+
/** The swap button's words, «Switch». */
|
|
34
|
+
'panel.swap',
|
|
35
|
+
/**
|
|
36
|
+
* `{name}` — read after `panel.swap` by a screen reader, «to {name}», so
|
|
37
|
+
* the button's name starts with the word on it (speech control matches it).
|
|
38
|
+
*/
|
|
39
|
+
'panel.swapTo',
|
|
40
|
+
/** The total's words, «Total, paid at the salon». */
|
|
41
|
+
'panel.total',
|
|
42
|
+
/** The button, «Continue». */
|
|
43
|
+
'panel.continue',
|
|
44
|
+
];
|
|
45
|
+
const NOTE_ICONS = { change: ArrowLeftRight, payment: CreditCard, place: MapPin };
|
|
46
|
+
const MAX_ALTERNATIVES = 2;
|
|
47
|
+
function Face({ photoUrl }) {
|
|
48
|
+
if (!photoUrl)
|
|
49
|
+
return null;
|
|
50
|
+
return (_jsx("img", { src: photoUrl, alt: "", width: 24, height: 24, decoding: "async", className: "size-6 shrink-0 rounded-full object-cover" }));
|
|
51
|
+
}
|
|
52
|
+
export function BookingPanel({ labels, format, who, services, startTs, stylist, alternatives, onSwap, totalOre, onContinue, canContinue = false, notes = [], classNames, }) {
|
|
53
|
+
const notChosen = (_jsx("span", { className: "text-muted-foreground", children: renderLabel(labels['panel.notChosen']) }));
|
|
54
|
+
const swaps = onSwap ? (alternatives ?? []).slice(0, MAX_ALTERNATIVES) : [];
|
|
55
|
+
const rows = [
|
|
56
|
+
{ name: labels['panel.who'], value: who ?? notChosen },
|
|
57
|
+
{ name: labels['panel.service'], value: services ?? notChosen },
|
|
58
|
+
{
|
|
59
|
+
name: labels['panel.time'],
|
|
60
|
+
value: startTs === null ? notChosen : format.clock.dateTime(startTs),
|
|
61
|
+
strong: startTs !== null,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
if (stylist !== undefined) {
|
|
65
|
+
rows.push({
|
|
66
|
+
name: labels['panel.stylist'],
|
|
67
|
+
value: stylist ? (_jsxs("span", { className: "inline-flex flex-wrap items-center gap-1.5", children: [_jsx(Face, { photoUrl: stylist.photoUrl }), format.stylistName(stylist.name), stylist.firstAvailable && ' ', stylist.firstAvailable && (_jsx("span", { className: "text-xs text-muted-foreground", children: renderLabel(labels['panel.firstAvailable']) }))] })) : (notChosen),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return (_jsxs("aside", { "aria-labelledby": "booking-panel-heading", "data-testid": "booking-panel", className: slotClass(classNames, 'root', 'rounded-lg border border-border bg-card p-5 shadow-sm'), children: [_jsx("h2", { id: "booking-panel-heading", className: slotClass(classNames, 'heading', 'font-sans text-xl font-bold'), children: renderLabel(labels['panel.heading']) }), _jsx("dl", { className: "mt-3 divide-y divide-border text-sm", children: rows.map((row) => (_jsxs("div", { className: "flex gap-3 py-2.5", children: [_jsx("dt", { className: "w-14 shrink-0 text-muted-foreground", children: renderLabel(row.name) }), _jsx("dd", { className: cn('min-w-0 flex-1', row.strong && 'font-semibold tabular-nums'), children: row.value })] }, fillLabel(row.name, {})))) }), swaps.length > 0 && (_jsxs("p", { className: "flex flex-wrap items-center gap-x-2 gap-y-1 ps-[4.25rem] pb-2 text-sm text-muted-foreground", children: [renderLabel(labels['panel.alsoFree']), swaps.map((alternative) => (_jsxs("span", { className: "inline-flex items-center gap-1.5", children: [_jsx(Face, { photoUrl: alternative.photoUrl }), format.stylistName(alternative.name), _jsxs("button", { type: "button", onClick: () => onSwap?.(alternative.resourceId), className: "font-semibold text-primary underline-offset-4 hover:underline focus-visible:underline focus-visible:outline-none", children: [renderLabel(labels['panel.swap']), ' ', _jsx("span", { className: "sr-only", children: fillLabel(labels['panel.swapTo'], {
|
|
71
|
+
name: format.stylistName(alternative.name),
|
|
72
|
+
}) })] })] }, alternative.resourceId)))] })), totalOre !== null && (_jsxs("p", { className: slotClass(classNames, 'total', 'mt-2 flex items-baseline justify-between gap-3 border-t border-border pt-3 text-sm'), children: [_jsx("span", { className: "text-muted-foreground", children: renderLabel(labels['panel.total']) }), _jsx("span", { className: "font-sans text-lg font-bold tabular-nums", children: format.price(totalOre) })] })), onContinue && (_jsx(BookingButton, { size: "lg", className: slotClass(classNames, 'continue', 'mt-4 w-full'), disabled: !canContinue, onClick: onContinue, children: renderLabel(labels['panel.continue']) })), notes.length > 0 && (_jsx("ul", { className: "mt-4 space-y-2 text-sm text-muted-foreground", children: notes.map((note, index) => {
|
|
73
|
+
const Icon = NOTE_ICONS[note.kind];
|
|
74
|
+
return (_jsxs("li", { className: "flex items-center gap-2", children: [_jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0" }), _jsx("span", { children: note.text })] }, `${note.kind}-${index}`));
|
|
75
|
+
}) }))] }));
|
|
76
|
+
}
|
|
@@ -15,7 +15,20 @@ import type { WizardItem } from './types.js';
|
|
|
15
15
|
* link to a page that 404s reads as a booking that did not quite happen.
|
|
16
16
|
*/
|
|
17
17
|
export declare const CONFIRMATION_LABEL_KEYS: readonly ["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"];
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Opt-in keys for `layout="hero"`. Each piece is drawn only when its label is
|
|
20
|
+
* there, so a pack can take the leaf and the buttons without the tip.
|
|
21
|
+
*
|
|
22
|
+
* - `confirmation.sent` — under the heading, «The confirmation is on its way by text and e-mail».
|
|
23
|
+
* - `confirmation.leafTime` `{time}` — the leaf's last line, «at {time}».
|
|
24
|
+
* - `confirmation.detail.service` / `.stylist` / `.where` — the details card's row names.
|
|
25
|
+
* - `confirmation.duration` `{minutes}` — after the service, «{minutes} min».
|
|
26
|
+
* - `confirmation.directions` — the second button (needs `directionsHref`).
|
|
27
|
+
* - `confirmation.tip.heading` / `confirmation.tip.body` — the «before you come» card.
|
|
28
|
+
* - `confirmation.manageUntil` `{day}` `{time}` — after «change or cancel», until when it is free.
|
|
29
|
+
*/
|
|
30
|
+
export declare const CONFIRMATION_HERO_LABEL_KEYS: readonly ["confirmation.sent", "confirmation.leafTime", "confirmation.detail.service", "confirmation.detail.stylist", "confirmation.detail.where", "confirmation.duration", "confirmation.directions", "confirmation.tip.heading", "confirmation.tip.body", "confirmation.manageUntil"];
|
|
31
|
+
export type ConfirmationLabels = Record<(typeof CONFIRMATION_LABEL_KEYS)[number], BookingLabel> & Partial<Record<(typeof CONFIRMATION_HERO_LABEL_KEYS)[number], BookingLabel>>;
|
|
19
32
|
/**
|
|
20
33
|
* One booked line (one person's appointment). One array rather than parallel
|
|
21
34
|
* lists, because every part answers about the SAME line.
|
|
@@ -27,6 +40,8 @@ export interface ConfirmationLine {
|
|
|
27
40
|
bookingId: string;
|
|
28
41
|
/** Who takes this line; `null` = «first available» not worth naming. Cleaned with `format.stylistName`. */
|
|
29
42
|
stylistName: string | null;
|
|
43
|
+
/** Their face, for `layout="hero"`. */
|
|
44
|
+
stylistPhotoUrl?: string | null;
|
|
30
45
|
/** Where this line's own booking is managed; `null` draws no link. */
|
|
31
46
|
manageHref: string | null;
|
|
32
47
|
/** When this line starts (a sequential party runs back to back). */
|
|
@@ -78,9 +93,20 @@ export interface ConfirmationProps {
|
|
|
78
93
|
onStartOver?: () => void;
|
|
79
94
|
/** «today» is relative to this. Read once, so it cannot change mid-card. */
|
|
80
95
|
now?: number;
|
|
96
|
+
/**
|
|
97
|
+
* `card` (the default) — heading, one card, text links. `hero` — the day as
|
|
98
|
+
* a large calendar leaf, a details card (service, stylist, where), the
|
|
99
|
+
* calendar and directions as buttons, an optional tip, and until when a
|
|
100
|
+
* change is free. Opt-in: the extra words are `CONFIRMATION_HERO_LABEL_KEYS`.
|
|
101
|
+
*/
|
|
102
|
+
layout?: 'card' | 'hero';
|
|
103
|
+
/** With `hero`: a maps link for the directions button. `null` draws no button. */
|
|
104
|
+
directionsHref?: string | null;
|
|
105
|
+
/** With `hero`: until when a change or cancellation is free. `null` says nothing. */
|
|
106
|
+
freeChangeUntilTs?: number | null;
|
|
81
107
|
format: BookingFormat;
|
|
82
108
|
labels: ConfirmationLabels;
|
|
83
109
|
classNames?: SlotClassNames<ConfirmationSlot>;
|
|
84
110
|
components?: ConfirmationComponents;
|
|
85
111
|
}
|
|
86
|
-
export declare function Confirmation({ lines: rawLines, startTs, totalOre, address, calendarHref, calendarFileName, portalHref, onStartOver, now, format, labels, classNames, components, }: ConfirmationProps): import("react").JSX.Element;
|
|
112
|
+
export declare function Confirmation({ lines: rawLines, startTs, totalOre, address, calendarHref, calendarFileName, portalHref, onStartOver, now, layout, directionsHref, freeChangeUntilTs, format, labels, classNames, components, }: ConfirmationProps): import("react").JSX.Element;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { CalendarPlus, MapPin } from 'lucide-react';
|
|
4
|
+
import { cn } from '../lib/utils.js';
|
|
3
5
|
import { joinLabelParts, renderLabel } from './internal/label-parts.js';
|
|
4
|
-
import { BookingButton } from './internal/ui.js';
|
|
6
|
+
import { BookingButton, bookingButtonClass } from './internal/ui.js';
|
|
5
7
|
import { visitName } from './internal/visit.js';
|
|
6
8
|
import { fillLabel, labelText } from './labels.js';
|
|
7
9
|
import { slotClass } from './slots.js';
|
|
@@ -37,13 +39,40 @@ export const CONFIRMATION_LABEL_KEYS = [
|
|
|
37
39
|
'confirmation.portal',
|
|
38
40
|
'confirmation.startOver',
|
|
39
41
|
];
|
|
42
|
+
/**
|
|
43
|
+
* Opt-in keys for `layout="hero"`. Each piece is drawn only when its label is
|
|
44
|
+
* there, so a pack can take the leaf and the buttons without the tip.
|
|
45
|
+
*
|
|
46
|
+
* - `confirmation.sent` — under the heading, «The confirmation is on its way by text and e-mail».
|
|
47
|
+
* - `confirmation.leafTime` `{time}` — the leaf's last line, «at {time}».
|
|
48
|
+
* - `confirmation.detail.service` / `.stylist` / `.where` — the details card's row names.
|
|
49
|
+
* - `confirmation.duration` `{minutes}` — after the service, «{minutes} min».
|
|
50
|
+
* - `confirmation.directions` — the second button (needs `directionsHref`).
|
|
51
|
+
* - `confirmation.tip.heading` / `confirmation.tip.body` — the «before you come» card.
|
|
52
|
+
* - `confirmation.manageUntil` `{day}` `{time}` — after «change or cancel», until when it is free.
|
|
53
|
+
*/
|
|
54
|
+
export const CONFIRMATION_HERO_LABEL_KEYS = [
|
|
55
|
+
'confirmation.sent',
|
|
56
|
+
'confirmation.leafTime',
|
|
57
|
+
'confirmation.detail.service',
|
|
58
|
+
'confirmation.detail.stylist',
|
|
59
|
+
'confirmation.detail.where',
|
|
60
|
+
'confirmation.duration',
|
|
61
|
+
'confirmation.directions',
|
|
62
|
+
'confirmation.tip.heading',
|
|
63
|
+
'confirmation.tip.body',
|
|
64
|
+
'confirmation.manageUntil',
|
|
65
|
+
];
|
|
40
66
|
/** One row of the family card: the line and its own manage link. Renders an `<li>`. */
|
|
41
67
|
export function DefaultPartyLine({ line, text, manageLabel, manageAriaLabel, linkClassName, }) {
|
|
42
68
|
return (_jsxs("li", { className: "flex flex-wrap items-baseline gap-x-3", children: [_jsx("span", { className: "tabular-nums", children: text }), line.manageHref && (_jsx("a", { href: line.manageHref, "aria-label": manageAriaLabel, className: linkClassName ?? 'text-sm font-semibold text-primary underline underline-offset-4', children: manageLabel }))] }));
|
|
43
69
|
}
|
|
44
70
|
const SEPARATOR = ' · ';
|
|
45
|
-
export function Confirmation({ lines: rawLines, startTs, totalOre, address = null, calendarHref = null, calendarFileName = 'booking.ics', portalHref = null, onStartOver, now = Date.now(), format, labels, classNames, components, }) {
|
|
71
|
+
export function Confirmation({ lines: rawLines, startTs, totalOre, address = null, calendarHref = null, calendarFileName = 'booking.ics', portalHref = null, onStartOver, now = Date.now(), layout = 'card', directionsHref = null, freeChangeUntilTs = null, format, labels, classNames, components, }) {
|
|
46
72
|
const { clock } = format;
|
|
73
|
+
if (layout === 'hero') {
|
|
74
|
+
return (_jsx(HeroConfirmation, { lines: rawLines, startTs: startTs, totalOre: totalOre, address: address, calendarHref: calendarHref, calendarFileName: calendarFileName, portalHref: portalHref, onStartOver: onStartOver, now: now, directionsHref: directionsHref, freeChangeUntilTs: freeChangeUntilTs, format: format, labels: labels, classNames: classNames, components: components }));
|
|
75
|
+
}
|
|
47
76
|
// Cleaned here as well as upstream: a restored confirmation carries whatever
|
|
48
77
|
// name was resolved when it was stored.
|
|
49
78
|
const lines = rawLines.map((line) => ({
|
|
@@ -101,3 +130,83 @@ function PartyCard({ lines, totalOre, now, format, labels, classNames, PartyLine
|
|
|
101
130
|
}), linkClassName: slotClass(classNames, 'link', 'text-sm font-semibold text-primary underline underline-offset-4') }, index));
|
|
102
131
|
}) }), _jsx("p", { className: "font-semibold", children: renderLabel(labels['confirmation.party.total'], { total: format.price(totalOre) }) })] }));
|
|
103
132
|
}
|
|
133
|
+
/** A visit's minutes: every service in it, back to back. */
|
|
134
|
+
function visitMinutes(item) {
|
|
135
|
+
return [item.service, ...(item.extraServices ?? [])].reduce((minutes, service) => minutes + service.durationMinutes, 0);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* `layout="hero"`: the day first, as a large calendar leaf, then what was
|
|
139
|
+
* booked, and the two things a parent does next — put it in the calendar and
|
|
140
|
+
* find the way — as buttons rather than links.
|
|
141
|
+
*/
|
|
142
|
+
function HeroConfirmation({ lines: rawLines, startTs, totalOre, address, calendarHref, calendarFileName, portalHref, onStartOver, now, directionsHref, freeChangeUntilTs, format, labels, classNames, components, }) {
|
|
143
|
+
const { clock } = format;
|
|
144
|
+
const lines = rawLines.map((line) => ({
|
|
145
|
+
...line,
|
|
146
|
+
stylistName: line.stylistName ? format.stylistName(line.stylistName) || null : null,
|
|
147
|
+
}));
|
|
148
|
+
const first = lines[0];
|
|
149
|
+
const [, month, day] = clock.dayKey(startTs).split('-').map(Number);
|
|
150
|
+
const linkClass = slotClass(classNames, 'link', 'font-semibold text-primary underline underline-offset-4');
|
|
151
|
+
const label = (key) => labels[key];
|
|
152
|
+
const when = renderLabel(labels['confirmation.when'], {
|
|
153
|
+
day: clock.dayLabel(startTs, now),
|
|
154
|
+
time: clock.formatTime(startTs),
|
|
155
|
+
});
|
|
156
|
+
const rows = [];
|
|
157
|
+
// A pack without the hero's row names still says what was booked: the card
|
|
158
|
+
// layout's one line, rather than a confirmation with no service on it.
|
|
159
|
+
const plainLine = first && lines.length === 1 && !label('confirmation.detail.service') ? (_jsx("p", { className: slotClass(classNames, 'card', 'rounded-lg border border-border bg-card px-5 py-4 font-medium'), children: joinLabelParts([
|
|
160
|
+
first.item.bookedForName
|
|
161
|
+
? renderLabel(labels['confirmation.serviceFor'], {
|
|
162
|
+
service: visitName(first.item),
|
|
163
|
+
name: first.item.bookedForName,
|
|
164
|
+
})
|
|
165
|
+
: visitName(first.item),
|
|
166
|
+
first.stylistName,
|
|
167
|
+
when,
|
|
168
|
+
format.price(first.priceOre),
|
|
169
|
+
], SEPARATOR) })) : null;
|
|
170
|
+
if (first && lines.length === 1) {
|
|
171
|
+
const service = first.item.bookedForName
|
|
172
|
+
? renderLabel(labels['confirmation.serviceFor'], {
|
|
173
|
+
service: visitName(first.item),
|
|
174
|
+
name: first.item.bookedForName,
|
|
175
|
+
})
|
|
176
|
+
: visitName(first.item);
|
|
177
|
+
const duration = label('confirmation.duration');
|
|
178
|
+
if (label('confirmation.detail.service')) {
|
|
179
|
+
rows.push({
|
|
180
|
+
name: label('confirmation.detail.service'),
|
|
181
|
+
// The price stays with what was booked, as on the card layout.
|
|
182
|
+
value: joinLabelParts([
|
|
183
|
+
service,
|
|
184
|
+
duration ? renderLabel(duration, { minutes: visitMinutes(first.item) }) : null,
|
|
185
|
+
format.price(first.priceOre),
|
|
186
|
+
], SEPARATOR),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (first.stylistName && label('confirmation.detail.stylist')) {
|
|
190
|
+
rows.push({
|
|
191
|
+
name: label('confirmation.detail.stylist'),
|
|
192
|
+
value: (_jsxs("span", { className: "inline-flex items-center gap-1.5", children: [first.stylistPhotoUrl && (_jsx("img", { src: first.stylistPhotoUrl, alt: "", width: 24, height: 24, decoding: "async", className: "size-6 shrink-0 rounded-full object-cover" })), first.stylistName] })),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (address && label('confirmation.detail.where')) {
|
|
197
|
+
rows.push({ name: label('confirmation.detail.where'), value: address });
|
|
198
|
+
}
|
|
199
|
+
const manageUntil = label('confirmation.manageUntil');
|
|
200
|
+
return (_jsxs("section", { "aria-labelledby": "booking-confirmed-heading", className: slotClass(classNames, 'root', 'space-y-5'), children: [_jsxs("div", { className: "flex flex-col items-center gap-3 pt-2 text-center", children: [_jsxs("span", { "aria-hidden": "true", "data-testid": "confirmation-leaf", className: "flex w-28 flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm", children: [_jsx("span", { className: "bg-primary px-2 py-1 text-xs font-bold uppercase tracking-wider text-primary-foreground", children: clock.weekday(startTs) }), _jsx("span", { className: "pt-2 font-sans text-5xl font-bold leading-none tabular-nums", children: day }), _jsx("span", { className: "pt-1 text-sm text-muted-foreground", children: month ? clock.monthName(month) : '' }), _jsx("span", { className: "pb-2 text-sm font-semibold tabular-nums", children: label('confirmation.leafTime')
|
|
201
|
+
? fillLabel(label('confirmation.leafTime'), {
|
|
202
|
+
time: clock.formatTime(startTs),
|
|
203
|
+
})
|
|
204
|
+
: clock.formatTime(startTs) })] }), _jsx("h2", { id: "booking-confirmed-heading", tabIndex: -1, className: slotClass(classNames, 'heading', 'font-sans text-2xl font-bold outline-none md:text-3xl'), children: labels['confirmation.heading'] }), _jsx("p", { className: "sr-only", children: when }), label('confirmation.sent') && (_jsx("p", { className: "text-sm text-muted-foreground", children: renderLabel(label('confirmation.sent')) }))] }), plainLine, lines.length > 1 && (_jsx(PartyCard, { lines: lines, totalOre: totalOre, now: now, format: format, labels: labels, classNames: classNames, PartyLine: components?.PartyLine ?? DefaultPartyLine })), rows.length > 0 && (_jsx("dl", { className: slotClass(classNames, 'card', 'divide-y divide-border rounded-lg border border-border bg-card px-5'), children: rows.map((row) => (_jsxs("div", { className: "flex items-center gap-4 py-3 text-sm", children: [_jsx("dt", { className: "w-16 shrink-0 text-muted-foreground", children: renderLabel(row.name) }), _jsx("dd", { className: "min-w-0 font-medium", children: row.value })] }, labelText(row.name)))) })), (calendarHref || (directionsHref && label('confirmation.directions'))) && (_jsxs("div", { className: "grid grid-cols-2 gap-2", children: [calendarHref && (_jsxs("a", { href: calendarHref, download: calendarFileName, className: bookingButtonClass({ size: 'lg', className: 'gap-2' }), children: [_jsx(CalendarPlus, { "aria-hidden": "true", className: "size-4" }), labels['confirmation.calendar']] })), directionsHref && label('confirmation.directions') && (_jsxs("a", { href: directionsHref, target: "_blank", rel: "noopener", className: bookingButtonClass({
|
|
205
|
+
variant: 'outline',
|
|
206
|
+
size: 'lg',
|
|
207
|
+
className: cn('gap-2', !calendarHref && 'col-span-2'),
|
|
208
|
+
}), children: [_jsx(MapPin, { "aria-hidden": "true", className: "size-4" }), renderLabel(label('confirmation.directions'))] }))] })), label('confirmation.tip.heading') && label('confirmation.tip.body') && (_jsxs("div", { className: "rounded-lg bg-muted px-5 py-4 text-sm", children: [_jsx("p", { className: "font-semibold", children: renderLabel(label('confirmation.tip.heading')) }), _jsx("p", { className: "text-muted-foreground", children: renderLabel(label('confirmation.tip.body')) })] })), lines.length === 1 && first?.manageHref && (_jsxs("p", { className: "text-center text-sm", children: [_jsx("a", { href: first.manageHref, className: linkClass, children: labels['confirmation.manage'] }), manageUntil && freeChangeUntilTs !== null && freeChangeUntilTs !== undefined && (_jsxs("span", { className: "text-muted-foreground", children: [SEPARATOR, fillLabel(manageUntil, {
|
|
209
|
+
day: clock.dayLabel(freeChangeUntilTs, now),
|
|
210
|
+
time: clock.formatTime(freeChangeUntilTs),
|
|
211
|
+
})] }))] })), portalHref && (_jsx("p", { className: "text-center text-sm", children: _jsx("a", { href: portalHref, className: linkClass, children: labels['confirmation.portal'] }) })), onStartOver && (_jsx("div", { className: "flex justify-center", children: _jsx(BookingButton, { variant: "outline", className: slotClass(classNames, 'startOver'), onClick: onStartOver, children: labels['confirmation.startOver'] }) }))] }));
|
|
212
|
+
}
|
package/dist/booking/index.d.ts
CHANGED
package/dist/booking/index.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// Styles: import `@medalsocial/meda/booking/styles.css` and
|
|
8
8
|
// `@medalsocial/meda/primitives/styles.css` after the bridge.
|
|
9
9
|
export * from './add-child-sheet.js';
|
|
10
|
+
export * from './booking-panel.js';
|
|
10
11
|
export * from './booking-recap.js';
|
|
11
12
|
export * from './booking-skeleton.js';
|
|
12
13
|
export * from './confirmation.js';
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { ADD_CHILD_SHEET_LABEL_KEYS } from './add-child-sheet.js';
|
|
2
|
+
import { BOOKING_PANEL_LABEL_KEYS } from './booking-panel.js';
|
|
2
3
|
import { BOOKING_RECAP_LABEL_KEYS } from './booking-recap.js';
|
|
3
4
|
import { BOOKING_SKELETON_LABEL_KEYS } from './booking-skeleton.js';
|
|
4
|
-
import { CONFIRMATION_LABEL_KEYS } from './confirmation.js';
|
|
5
|
+
import { CONFIRMATION_HERO_LABEL_KEYS, CONFIRMATION_LABEL_KEYS } from './confirmation.js';
|
|
5
6
|
import { DETAILS_SCREEN_LABEL_KEYS } from './details-screen.js';
|
|
6
7
|
import type { BookingLabel } from './labels.js';
|
|
7
8
|
import { LOGIN_SHEET_LABEL_KEYS } from './login-sheet.js';
|
|
@@ -17,7 +18,7 @@ import { REBOOK_CARDS_LABEL_KEYS } from './portal/rebook-cards.js';
|
|
|
17
18
|
import { UPCOMING_BOOKINGS_LABEL_KEYS } from './portal/upcoming-bookings.js';
|
|
18
19
|
import { VIPPS_LINK_ROW_LABEL_KEYS } from './portal/vipps-link-row.js';
|
|
19
20
|
import { VISIT_HISTORY_LABEL_KEYS } from './portal/visit-history.js';
|
|
20
|
-
import { SERVICE_SCREEN_LABEL_KEYS, SERVICE_SCREEN_MULTI_LABEL_KEYS } from './service-screen.js';
|
|
21
|
+
import { SERVICE_SCREEN_LABEL_KEYS, SERVICE_SCREEN_MULTI_LABEL_KEYS, SERVICE_SCREEN_SEARCH_LABEL_KEYS } from './service-screen.js';
|
|
21
22
|
import { STYLIST_SCREEN_LABEL_KEYS, STYLIST_SCREEN_SEAT_LABEL_KEYS } from './stylist-screen.js';
|
|
22
23
|
import { SUMMARY_BAR_HINT_LABEL_KEYS, SUMMARY_BAR_LABEL_KEYS } from './summary-bar.js';
|
|
23
24
|
import { TIME_SCREEN_FINDER_LABEL_KEYS, TIME_SCREEN_LABEL_KEYS } from './time-screen.js';
|
|
@@ -30,13 +31,13 @@ import { WHO_SCREEN_LABEL_KEYS, WHO_SCREEN_PARTY_LABEL_KEYS } from './who-screen
|
|
|
30
31
|
* (Screens that embed another screen already include its keys, e.g. the
|
|
31
32
|
* login sheet carries the login panel's, OTP slots' and Vipps button's.)
|
|
32
33
|
*/
|
|
33
|
-
export declare const BOOKING_LABEL_KEYS: readonly ("addChild.trigger" | "addChild.title" | "addChild.description.saved" | "addChild.description.local" | "addChild.name" | "addChild.birthYear" | "addChild.birthYearPlaceholder" | "addChild.birthMonth" | "addChild.birthMonthUnknown" | "addChild.notes" | "addChild.missing" | "addChild.submit" | "addChild.close" | "recap.label" | "recap.time" | "recap.line" | "recap.lineFor" | "recap.anyStylist" | "recap.total" | "recap.edit" | "recap.editLabel" | "recap.alsoFree" | "recap.swapTo" | "bookingSkeleton.opening" | "confirmation.heading" | "confirmation.serviceFor" | "confirmation.when" | "confirmation.party.heading" | "confirmation.party.line" | "confirmation.party.stylist" | "confirmation.party.total" | "confirmation.calendar" | "confirmation.manage" | "confirmation.manageFor" | "confirmation.portal" | "confirmation.startOver" | "details.heading" | "details.phone.label" | "details.phone.prefix" | "details.phone.error" | "details.name.label" | "details.known.label" | "details.known.for" | "details.known.self" | "details.known.adult" | "details.known.child" | "details.child.name" | "details.child.year" | "details.child.yearPlaceholder" | "details.email.label" | "details.email.help" | "details.email.lockedHelp" | "details.email.error" | "details.notes.label" | "details.notes.placeholder" | "details.terms.text" | "details.terms.link" | "details.marketing.text" | "details.error.maxParty" | "details.error.slotTaken" | "details.error.conflict" | "details.error.invalidInput" | "details.error.unconfigured" | "details.error.upstreamError" | "details.error.inProgress" | "details.call.link" | "details.call.none" | "details.submit" | "otp.label" | "vipps.button" | "vipps.unavailable" | "vipps.throttled" | "login.heading" | "login.codeHeading" | "login.or" | "login.continueEmail" | "login.emailIntro" | "login.emailLabel" | "login.sendCode" | "login.codeLabel" | "login.submitCode" | "login.codeHelp" | "login.codeHelpVipps" | "login.vippsSentTo" | "login.vippsSentToUnknown" | "login.cooldownWait" | "login.cooldownReady" | "login.resendIn" | "login.resend" | "login.switchEmail" | "login.useEmailInstead" | "login.notice.sent" | "login.notice.resent" | "login.notice.unreachable" | "login.notice.badEmail" | "login.notice.invalid" | "login.notice.throttled" | "login.notice.vippsInvalid" | "login.notice.vippsConflict" | "loginSheet.prompt" | "loginSheet.trigger" | "loginSheet.intro" | "loginSheet.vipps" | "loginSheet.close" | "time.heading" | "time.dayStrip.legend" | "time.nearest" | "time.current" | "time.nextFree" | "time.shortNotice" | "time.weekend.pct" | "time.weekend.mixed" | "time.month.show" | "time.month.hide" | "time.month.previous" | "time.month.next" | "time.month.heading" | "time.month.note" | "time.party.sequential" | "time.party.parallel" | "time.party.seat" | "time.party.seatSeparator" | "time.parallel.offer.two" | "time.parallel.offer.other" | "time.parallel.accept" | "time.empty.closed" | "time.empty.over" | "time.empty.full" | "time.empty.stylist" | "time.empty.unknown" | "time.empty.callLink" | "time.empty.callLinkAria" | "time.empty.callSuffix" | "time.empty.callPlain" | "time.loading" | "time.skeleton.dayPlaceholder" | "time.taken.message" | "time.taken.close" | "time.soonest.heading" | "time.soonest.pick" | "time.soonest.with" | "time.dayChip.free" | "time.dayChip.freeOne" | "time.dayChip.full" | "time.week.none" | "time.week.more" | "manage.heading" | "manage.serviceFallback" | "manage.card.named" | "manage.card.when" | "manage.card.whenWithStylist" | "manage.appointment.named" | "manage.appointment.namedSibilant" | "manage.appointment.unnamed" | "manage.ringUs" | "manage.ringUsOn" | "manage.callUs" | "manage.callUsOn" | "manage.error.windowPassed" | "manage.error.slotTaken" | "manage.error.notFound" | "manage.error.conflict" | "manage.error.invalidInput" | "manage.error.unavailable" | "manage.freeUntil" | "manage.remaining.in" | "manage.remaining.daysAndHours" | "manage.remaining.underMinute" | "manage.remaining.day.one" | "manage.remaining.day.other" | "manage.remaining.hour.one" | "manage.remaining.hour.other" | "manage.remaining.minute.one" | "manage.remaining.minute.other" | "manage.change" | "manage.cancel" | "manage.callToChange" | "manage.callToCancel" | "manage.blocked" | "manage.blocked.suffix" | "manage.blocked.plain" | "manage.calendar" | "manage.closed.cancelled" | "manage.closed.completed" | "manage.closed.noShow" | "manage.closed.pending" | "manage.closed.pendingCall" | "manage.closed.pendingCallPlain" | "manage.bookNew" | "manage.party.heading" | "manage.party.justThisOne" | "manage.party.unnamed" | "manage.party.note" | "manage.party.noteSuffix" | "manage.back" | "manage.reschedule.lead" | "manage.reschedule.leadWithStylist" | "manage.reschedule.failed" | "manage.reschedule.failedSuffix" | "manage.reschedule.loading" | "manage.move.question" | "manage.move.questionWithStylist" | "manage.move.surcharge" | "manage.move.confirm" | "manage.move.pickAnother" | "manage.cancel.question" | "manage.cancel.reasonLegend" | "manage.cancel.reason.illness" | "manage.cancel.reason.noLongerSuits" | "manage.cancel.reason.other" | "manage.cancel.confirm" | "manage.cancel.keep" | "manage.cancelled" | "manage.cancelled.findNew" | "manage.moved" | "manage.movedTo" | "manage.moved.view" | "manage.moved.noLink" | "manage.moved.noLinkSuffix" | "manage.portal" | "childCards.empty" | "childCards.neverVisited" | "childCards.lastVisit" | "childCards.lastVisitNoService" | "childCards.book" | "childCards.stylist" | "childCards.next" | "childCards.none" | "dataControls.heading" | "dataControls.export" | "dataControls.delete" | "dataControls.confirmHeading" | "dataControls.consequences" | "dataControls.confirmLabel" | "dataControls.confirmHelp" | "dataControls.wrongWord" | "dataControls.deleteForever" | "dataControls.cancel" | "dataControls.unreachable" | "dataControls.confirmWord" | "ageConfirm.heading" | "ageConfirm.lead" | "ageConfirm.legend" | "ageConfirm.birthYear" | "ageConfirm.birthMonth" | "ageConfirm.birthMonthUnknown" | "ageConfirm.confirm" | "ageConfirm.dismiss" | "familyEditor.heading" | "familyEditor.lead" | "familyEditor.empty" | "familyEditor.legend" | "familyEditor.add" | "familyEditor.name" | "familyEditor.birthYear" | "familyEditor.birthYearPlaceholder" | "familyEditor.birthMonth" | "familyEditor.birthMonthUnknown" | "familyEditor.stylist" | "familyEditor.stylistNone" | "familyEditor.stylistFormer" | "familyEditor.notes" | "familyEditor.notesPlaceholder" | "familyEditor.newRow" | "familyEditor.save" | "familyEditor.remove" | "familyEditor.removeNamed" | "familyEditor.removeRow" | "familyEditor.saved" | "familyEditor.savedNoDetails" | "familyEditor.partial" | "familyEditor.removed" | "familyEditor.unreachable" | "familyEditor.ageConfirmed" | "familyEditor.ageSaved" | "logout.button" | "logout.unreachable" | "portalShell.nav" | "portalShell.navBar" | "portalUnreachable.heading" | "portalUnreachable.body" | "portalUnreachable.retry" | "portalUnreachable.call" | "profileForm.heading" | "profileForm.firstName" | "profileForm.lastName" | "profileForm.phone" | "profileForm.email" | "profileForm.emailHelp" | "profileForm.save" | "profileForm.saved" | "profileForm.restored" | "profileForm.marketing" | "profileForm.unreachable" | "rebook.heading" | "rebook.empty" | "rebook.serviceFallback" | "rebook.anyStylist" | "rebook.cta" | "upcoming.heading" | "upcoming.empty" | "upcoming.book" | "upcoming.next" | "upcoming.today" | "upcoming.tomorrow" | "upcoming.inDays" | "upcoming.when" | "upcoming.serviceFallback" | "upcoming.anyStylist" | "upcoming.manage" | "upcoming.noManage" | "upcoming.noManageWithPhone" | "upcoming.call" | "vippsLink.heading" | "vippsLink.linked" | "vippsLink.pitch" | "vippsLink.button" | "vippsLink.success" | "vippsLink.conflict" | "vippsLink.failed" | "vippsLink.unavailable" | "vippsLink.throttled" | "history.heading" | "history.empty" | "history.serviceFallback" | "history.count.one" | "history.count.other" | "history.unknownAmount" | "service.multiHint" | "service.total" | "service.chooseFirst" | "service.continue" | "service.party.nothingBookable" | "service.tabDone" | "service.heading" | "service.categoriesLegend" | "service.sameAsLast" | "service.chooseOther" | "service.duration" | "service.phoneOnly" | "service.phoneOnlyLinkLabel" | "service.ageDivider.named" | "service.ageDivider.unnamed" | "service.party.listLabel" | "service.party.adultAlone" | "service.party.nothingFits" | "service.party.remove" | "stylist.heading" | "stylist.firstAvailable" | "stylist.firstAvailableSubtitle" | "stylist.firstAvailableBadge" | "stylist.pendingName" | "stylist.nextAvailable" | "stylist.loading" | "stylist.party.legend" | "stylist.party.sequential" | "stylist.party.sequentialMinutes" | "stylist.party.parallel.two" | "stylist.party.parallel.three" | "stylist.party.parallel.other" | "stylist.party.parallelMinutes" | "stylist.party.parallelNote.two" | "stylist.party.parallelNote.three" | "stylist.party.parallelNote.other" | "stylist.party.seatsHeading" | "stylist.party.seat" | "summary.placeholder.who" | "summary.placeholder.service" | "summary.next" | "summary.hint.who" | "summary.hint.service" | "summary.hint.when" | "who.heading" | "who.guest.groupLabel" | "who.guest.children.one" | "who.guest.children.other" | "who.guest.adult" | "who.guest.addedList" | "who.family.legend" | "who.family.self" | "who.family.limit" | "who.party.children" | "who.party.childrenNote" | "who.party.adult" | "who.party.adultNote" | "who.party.fewer" | "who.party.more" | "who.party.count")[];
|
|
34
|
+
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" | "panel.heading" | "panel.who" | "panel.service" | "panel.time" | "panel.stylist" | "panel.notChosen" | "panel.firstAvailable" | "panel.alsoFree" | "panel.swap" | "panel.swapTo" | "panel.total" | "panel.continue" | "recap.label" | "recap.time" | "recap.line" | "recap.lineFor" | "recap.anyStylist" | "recap.total" | "recap.edit" | "recap.editLabel" | "recap.alsoFree" | "recap.swapTo" | "bookingSkeleton.opening" | "confirmation.heading" | "confirmation.serviceFor" | "confirmation.when" | "confirmation.party.heading" | "confirmation.party.line" | "confirmation.party.stylist" | "confirmation.party.total" | "confirmation.calendar" | "confirmation.manage" | "confirmation.manageFor" | "confirmation.portal" | "confirmation.startOver" | "confirmation.sent" | "confirmation.leafTime" | "confirmation.detail.service" | "confirmation.detail.stylist" | "confirmation.detail.where" | "confirmation.duration" | "confirmation.directions" | "confirmation.tip.heading" | "confirmation.tip.body" | "confirmation.manageUntil" | "details.heading" | "details.phone.label" | "details.phone.prefix" | "details.phone.error" | "details.name.label" | "details.known.label" | "details.known.for" | "details.known.self" | "details.known.adult" | "details.known.child" | "details.child.name" | "details.child.year" | "details.child.yearPlaceholder" | "details.email.label" | "details.email.help" | "details.email.lockedHelp" | "details.email.error" | "details.notes.label" | "details.notes.placeholder" | "details.terms.text" | "details.terms.link" | "details.marketing.text" | "details.error.maxParty" | "details.error.slotTaken" | "details.error.conflict" | "details.error.invalidInput" | "details.error.unconfigured" | "details.error.upstreamError" | "details.error.inProgress" | "details.call.link" | "details.call.none" | "details.submit" | "otp.label" | "vipps.button" | "vipps.unavailable" | "vipps.throttled" | "login.heading" | "login.codeHeading" | "login.or" | "login.continueEmail" | "login.emailIntro" | "login.emailLabel" | "login.sendCode" | "login.codeLabel" | "login.submitCode" | "login.codeHelp" | "login.codeHelpVipps" | "login.vippsSentTo" | "login.vippsSentToUnknown" | "login.cooldownWait" | "login.cooldownReady" | "login.resendIn" | "login.resend" | "login.switchEmail" | "login.useEmailInstead" | "login.notice.sent" | "login.notice.resent" | "login.notice.unreachable" | "login.notice.badEmail" | "login.notice.invalid" | "login.notice.throttled" | "login.notice.vippsInvalid" | "login.notice.vippsConflict" | "loginSheet.prompt" | "loginSheet.trigger" | "loginSheet.intro" | "loginSheet.vipps" | "loginSheet.close" | "time.heading" | "time.dayStrip.legend" | "time.nearest" | "time.current" | "time.nextFree" | "time.shortNotice" | "time.weekend.pct" | "time.weekend.mixed" | "time.month.show" | "time.month.hide" | "time.month.previous" | "time.month.next" | "time.month.heading" | "time.month.note" | "time.party.sequential" | "time.party.parallel" | "time.party.seat" | "time.party.seatSeparator" | "time.parallel.offer.two" | "time.parallel.offer.other" | "time.parallel.accept" | "time.empty.closed" | "time.empty.over" | "time.empty.full" | "time.empty.stylist" | "time.empty.unknown" | "time.empty.callLink" | "time.empty.callLinkAria" | "time.empty.callSuffix" | "time.empty.callPlain" | "time.loading" | "time.skeleton.dayPlaceholder" | "time.taken.message" | "time.taken.close" | "time.soonest.heading" | "time.soonest.pick" | "time.soonest.with" | "time.dayChip.free" | "time.dayChip.freeOne" | "time.dayChip.full" | "time.week.none" | "time.week.more" | "manage.heading" | "manage.serviceFallback" | "manage.card.named" | "manage.card.when" | "manage.card.whenWithStylist" | "manage.appointment.named" | "manage.appointment.namedSibilant" | "manage.appointment.unnamed" | "manage.ringUs" | "manage.ringUsOn" | "manage.callUs" | "manage.callUsOn" | "manage.error.windowPassed" | "manage.error.slotTaken" | "manage.error.notFound" | "manage.error.conflict" | "manage.error.invalidInput" | "manage.error.unavailable" | "manage.freeUntil" | "manage.remaining.in" | "manage.remaining.daysAndHours" | "manage.remaining.underMinute" | "manage.remaining.day.one" | "manage.remaining.day.other" | "manage.remaining.hour.one" | "manage.remaining.hour.other" | "manage.remaining.minute.one" | "manage.remaining.minute.other" | "manage.change" | "manage.cancel" | "manage.callToChange" | "manage.callToCancel" | "manage.blocked" | "manage.blocked.suffix" | "manage.blocked.plain" | "manage.calendar" | "manage.closed.cancelled" | "manage.closed.completed" | "manage.closed.noShow" | "manage.closed.pending" | "manage.closed.pendingCall" | "manage.closed.pendingCallPlain" | "manage.bookNew" | "manage.party.heading" | "manage.party.justThisOne" | "manage.party.unnamed" | "manage.party.note" | "manage.party.noteSuffix" | "manage.back" | "manage.reschedule.lead" | "manage.reschedule.leadWithStylist" | "manage.reschedule.failed" | "manage.reschedule.failedSuffix" | "manage.reschedule.loading" | "manage.move.question" | "manage.move.questionWithStylist" | "manage.move.surcharge" | "manage.move.confirm" | "manage.move.pickAnother" | "manage.cancel.question" | "manage.cancel.reasonLegend" | "manage.cancel.reason.illness" | "manage.cancel.reason.noLongerSuits" | "manage.cancel.reason.other" | "manage.cancel.confirm" | "manage.cancel.keep" | "manage.cancelled" | "manage.cancelled.findNew" | "manage.moved" | "manage.movedTo" | "manage.moved.view" | "manage.moved.noLink" | "manage.moved.noLinkSuffix" | "manage.portal" | "childCards.empty" | "childCards.neverVisited" | "childCards.lastVisit" | "childCards.lastVisitNoService" | "childCards.book" | "childCards.stylist" | "childCards.next" | "childCards.none" | "dataControls.heading" | "dataControls.export" | "dataControls.delete" | "dataControls.confirmHeading" | "dataControls.consequences" | "dataControls.confirmLabel" | "dataControls.confirmHelp" | "dataControls.wrongWord" | "dataControls.deleteForever" | "dataControls.cancel" | "dataControls.unreachable" | "dataControls.confirmWord" | "ageConfirm.heading" | "ageConfirm.lead" | "ageConfirm.legend" | "ageConfirm.birthYear" | "ageConfirm.birthMonth" | "ageConfirm.birthMonthUnknown" | "ageConfirm.confirm" | "ageConfirm.dismiss" | "familyEditor.heading" | "familyEditor.lead" | "familyEditor.empty" | "familyEditor.legend" | "familyEditor.add" | "familyEditor.name" | "familyEditor.birthYear" | "familyEditor.birthYearPlaceholder" | "familyEditor.birthMonth" | "familyEditor.birthMonthUnknown" | "familyEditor.stylist" | "familyEditor.stylistNone" | "familyEditor.stylistFormer" | "familyEditor.notes" | "familyEditor.notesPlaceholder" | "familyEditor.newRow" | "familyEditor.save" | "familyEditor.remove" | "familyEditor.removeNamed" | "familyEditor.removeRow" | "familyEditor.saved" | "familyEditor.savedNoDetails" | "familyEditor.partial" | "familyEditor.removed" | "familyEditor.unreachable" | "familyEditor.ageConfirmed" | "familyEditor.ageSaved" | "logout.button" | "logout.unreachable" | "portalShell.nav" | "portalShell.navBar" | "portalUnreachable.heading" | "portalUnreachable.body" | "portalUnreachable.retry" | "portalUnreachable.call" | "profileForm.heading" | "profileForm.firstName" | "profileForm.lastName" | "profileForm.phone" | "profileForm.email" | "profileForm.emailHelp" | "profileForm.save" | "profileForm.saved" | "profileForm.restored" | "profileForm.marketing" | "profileForm.unreachable" | "rebook.heading" | "rebook.empty" | "rebook.serviceFallback" | "rebook.anyStylist" | "rebook.cta" | "upcoming.heading" | "upcoming.empty" | "upcoming.book" | "upcoming.next" | "upcoming.today" | "upcoming.tomorrow" | "upcoming.inDays" | "upcoming.when" | "upcoming.serviceFallback" | "upcoming.anyStylist" | "upcoming.manage" | "upcoming.noManage" | "upcoming.noManageWithPhone" | "upcoming.call" | "vippsLink.heading" | "vippsLink.linked" | "vippsLink.pitch" | "vippsLink.button" | "vippsLink.success" | "vippsLink.conflict" | "vippsLink.failed" | "vippsLink.unavailable" | "vippsLink.throttled" | "history.heading" | "history.empty" | "history.serviceFallback" | "history.count.one" | "history.count.other" | "history.unknownAmount" | "service.multiHint" | "service.total" | "service.chooseFirst" | "service.continue" | "service.party.nothingBookable" | "service.tabDone" | "service.heading" | "service.categoriesLegend" | "service.sameAsLast" | "service.chooseOther" | "service.duration" | "service.phoneOnly" | "service.phoneOnlyLinkLabel" | "service.ageDivider.named" | "service.ageDivider.unnamed" | "service.party.listLabel" | "service.party.adultAlone" | "service.party.nothingFits" | "service.party.remove" | "service.search" | "service.searchLabel" | "service.searchEmpty" | "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" | "stylist.party.seatsHeading" | "stylist.party.seat" | "summary.placeholder.who" | "summary.placeholder.service" | "summary.next" | "summary.hint.who" | "summary.hint.service" | "summary.hint.when" | "who.heading" | "who.guest.groupLabel" | "who.guest.children.one" | "who.guest.children.other" | "who.guest.adult" | "who.guest.addedList" | "who.family.legend" | "who.family.self" | "who.family.limit" | "who.party.children" | "who.party.childrenNote" | "who.party.adult" | "who.party.adultNote" | "who.party.fewer" | "who.party.more" | "who.party.count" | "who.party.adultFewer" | "who.party.adultMore" | "who.party.adultCount" | "who.party.lead" | "who.party.togetherHint")[];
|
|
34
35
|
export type BookingLabelKey = BookingRequiredLabelKey | BookingOptionalLabelKey;
|
|
35
36
|
/**
|
|
36
37
|
* Keys added after a screen first shipped, for a feature a caller opts into.
|
|
37
38
|
* A pack may leave them out; a screen using the feature needs them.
|
|
38
39
|
*/
|
|
39
|
-
export type BookingOptionalLabelKey = (typeof SERVICE_SCREEN_MULTI_LABEL_KEYS)[number] | (typeof BOOKING_RECAP_LABEL_KEYS)[number] | (typeof TIME_SCREEN_FINDER_LABEL_KEYS)[number] | (typeof STYLIST_SCREEN_SEAT_LABEL_KEYS)[number] | (typeof SUMMARY_BAR_HINT_LABEL_KEYS)[number] | (typeof WHO_SCREEN_PARTY_LABEL_KEYS)[number];
|
|
40
|
+
export type BookingOptionalLabelKey = (typeof SERVICE_SCREEN_MULTI_LABEL_KEYS)[number] | (typeof BOOKING_RECAP_LABEL_KEYS)[number] | (typeof TIME_SCREEN_FINDER_LABEL_KEYS)[number] | (typeof STYLIST_SCREEN_SEAT_LABEL_KEYS)[number] | (typeof SUMMARY_BAR_HINT_LABEL_KEYS)[number] | (typeof WHO_SCREEN_PARTY_LABEL_KEYS)[number] | (typeof SERVICE_SCREEN_SEARCH_LABEL_KEYS)[number] | (typeof CONFIRMATION_HERO_LABEL_KEYS)[number] | (typeof BOOKING_PANEL_LABEL_KEYS)[number];
|
|
40
41
|
/** Keys every complete pack defines. */
|
|
41
42
|
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];
|
|
42
43
|
/** A complete label pack for one locale. The opt-in keys may be absent. */
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { ADD_CHILD_SHEET_LABEL_KEYS } from './add-child-sheet.js';
|
|
2
|
+
import { BOOKING_PANEL_LABEL_KEYS } from './booking-panel.js';
|
|
2
3
|
import { BOOKING_RECAP_LABEL_KEYS } from './booking-recap.js';
|
|
3
4
|
import { BOOKING_SKELETON_LABEL_KEYS } from './booking-skeleton.js';
|
|
4
|
-
import { CONFIRMATION_LABEL_KEYS } from './confirmation.js';
|
|
5
|
+
import { CONFIRMATION_HERO_LABEL_KEYS, CONFIRMATION_LABEL_KEYS } from './confirmation.js';
|
|
5
6
|
import { DETAILS_SCREEN_LABEL_KEYS } from './details-screen.js';
|
|
6
7
|
import { LOGIN_SHEET_LABEL_KEYS } from './login-sheet.js';
|
|
7
8
|
import { MANAGE_SCREEN_LABEL_KEYS } from './manage-screen.js';
|
|
@@ -16,7 +17,7 @@ import { REBOOK_CARDS_LABEL_KEYS } from './portal/rebook-cards.js';
|
|
|
16
17
|
import { UPCOMING_BOOKINGS_LABEL_KEYS } from './portal/upcoming-bookings.js';
|
|
17
18
|
import { VIPPS_LINK_ROW_LABEL_KEYS } from './portal/vipps-link-row.js';
|
|
18
19
|
import { VISIT_HISTORY_LABEL_KEYS } from './portal/visit-history.js';
|
|
19
|
-
import { SERVICE_SCREEN_LABEL_KEYS, SERVICE_SCREEN_MULTI_LABEL_KEYS } from './service-screen.js';
|
|
20
|
+
import { SERVICE_SCREEN_LABEL_KEYS, SERVICE_SCREEN_MULTI_LABEL_KEYS, SERVICE_SCREEN_SEARCH_LABEL_KEYS, } from './service-screen.js';
|
|
20
21
|
import { STYLIST_SCREEN_LABEL_KEYS, STYLIST_SCREEN_SEAT_LABEL_KEYS } from './stylist-screen.js';
|
|
21
22
|
import { SUMMARY_BAR_HINT_LABEL_KEYS, SUMMARY_BAR_LABEL_KEYS } from './summary-bar.js';
|
|
22
23
|
import { TIME_SCREEN_FINDER_LABEL_KEYS, TIME_SCREEN_LABEL_KEYS } from './time-screen.js';
|
|
@@ -36,16 +37,19 @@ export const BOOKING_LABEL_KEYS = [
|
|
|
36
37
|
...ADD_CHILD_SHEET_LABEL_KEYS,
|
|
37
38
|
...SERVICE_SCREEN_LABEL_KEYS,
|
|
38
39
|
...SERVICE_SCREEN_MULTI_LABEL_KEYS,
|
|
40
|
+
...SERVICE_SCREEN_SEARCH_LABEL_KEYS,
|
|
39
41
|
...STYLIST_SCREEN_LABEL_KEYS,
|
|
40
42
|
...STYLIST_SCREEN_SEAT_LABEL_KEYS,
|
|
41
43
|
...TIME_SCREEN_LABEL_KEYS,
|
|
42
44
|
...TIME_SCREEN_FINDER_LABEL_KEYS,
|
|
43
45
|
...MANAGE_SCREEN_LABEL_KEYS,
|
|
44
46
|
...BOOKING_RECAP_LABEL_KEYS,
|
|
47
|
+
...BOOKING_PANEL_LABEL_KEYS,
|
|
45
48
|
...DETAILS_SCREEN_LABEL_KEYS,
|
|
46
49
|
...SUMMARY_BAR_LABEL_KEYS,
|
|
47
50
|
...SUMMARY_BAR_HINT_LABEL_KEYS,
|
|
48
51
|
...CONFIRMATION_LABEL_KEYS,
|
|
52
|
+
...CONFIRMATION_HERO_LABEL_KEYS,
|
|
49
53
|
...BOOKING_SKELETON_LABEL_KEYS,
|
|
50
54
|
...LOGIN_SHEET_LABEL_KEYS,
|
|
51
55
|
...PORTAL_SHELL_LABEL_KEYS,
|
|
@@ -34,7 +34,14 @@ export declare const SERVICE_SCREEN_LABEL_KEYS: readonly ["service.heading", "se
|
|
|
34
34
|
export declare const SERVICE_SCREEN_MULTI_LABEL_KEYS: readonly ["service.multiHint", "service.total", "service.chooseFirst", "service.continue", "service.party.nothingBookable", "service.tabDone"];
|
|
35
35
|
/** The multi-select step's labels, every one required (`ServiceSelection.labels`). */
|
|
36
36
|
export type ServiceMultiLabels = Record<(typeof SERVICE_SCREEN_MULTI_LABEL_KEYS)[number], BookingLabel>;
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Opt-in keys for the search field (`ServiceScreenProps.searchFor`):
|
|
39
|
+
* `service.search` is its placeholder («Search: beard, fringe, colour …»),
|
|
40
|
+
* `service.searchLabel` its accessible name, and `service.searchEmpty`
|
|
41
|
+
* `{query}` what shows when nothing matches. Without all three, no field.
|
|
42
|
+
*/
|
|
43
|
+
export declare const SERVICE_SCREEN_SEARCH_LABEL_KEYS: readonly ["service.search", "service.searchLabel", "service.searchEmpty"];
|
|
44
|
+
export type ServiceScreenLabels = Record<(typeof SERVICE_SCREEN_LABEL_KEYS)[number], BookingLabel> & Partial<ServiceMultiLabels> & Partial<Record<(typeof SERVICE_SCREEN_SEARCH_LABEL_KEYS)[number], BookingLabel>>;
|
|
38
45
|
/**
|
|
39
46
|
* - `root` — the `<section>`
|
|
40
47
|
* - `heading` — the `<h2>`
|
|
@@ -206,6 +213,12 @@ export interface ServiceScreenProps {
|
|
|
206
213
|
* Absent: the one-tap step, unchanged.
|
|
207
214
|
*/
|
|
208
215
|
selection?: ServiceSelection;
|
|
216
|
+
/**
|
|
217
|
+
* Whose list gets a search field, by person (0 for a party of one): a
|
|
218
|
+
* grown-up's catalogue is long, a child's is short. Needs the
|
|
219
|
+
* `service.search*` labels. Default: nobody.
|
|
220
|
+
*/
|
|
221
|
+
searchFor?: (personIndex: number) => boolean;
|
|
209
222
|
}
|
|
210
223
|
export declare function ServiceScreen(props: ServiceScreenProps): import("react").JSX.Element;
|
|
211
224
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { Search } from 'lucide-react';
|
|
3
4
|
import { useState } from 'react';
|
|
4
5
|
import { cn } from '../lib/utils.js';
|
|
5
6
|
import { renderLabel } from './internal/label-parts.js';
|
|
@@ -57,6 +58,17 @@ export const SERVICE_SCREEN_MULTI_LABEL_KEYS = [
|
|
|
57
58
|
'service.party.nothingBookable',
|
|
58
59
|
'service.tabDone',
|
|
59
60
|
];
|
|
61
|
+
/**
|
|
62
|
+
* Opt-in keys for the search field (`ServiceScreenProps.searchFor`):
|
|
63
|
+
* `service.search` is its placeholder («Search: beard, fringe, colour …»),
|
|
64
|
+
* `service.searchLabel` its accessible name, and `service.searchEmpty`
|
|
65
|
+
* `{query}` what shows when nothing matches. Without all three, no field.
|
|
66
|
+
*/
|
|
67
|
+
export const SERVICE_SCREEN_SEARCH_LABEL_KEYS = [
|
|
68
|
+
'service.search',
|
|
69
|
+
'service.searchLabel',
|
|
70
|
+
'service.searchEmpty',
|
|
71
|
+
];
|
|
60
72
|
const HEADING_ID = 'booking-service-heading';
|
|
61
73
|
function groupHeadingId(category) {
|
|
62
74
|
return `booking-services-${category}`;
|
|
@@ -95,6 +107,32 @@ function cardProps(shared) {
|
|
|
95
107
|
function tickedBy(selection, personIndex) {
|
|
96
108
|
return new Set((selection.lists[personIndex] ?? []).map((service) => service.id));
|
|
97
109
|
}
|
|
110
|
+
/** Lower case, accents gone: «Fargé» finds «farge», «ø» stays «ø». */
|
|
111
|
+
function fold(text) {
|
|
112
|
+
return text
|
|
113
|
+
.normalize('NFD')
|
|
114
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
115
|
+
.toLowerCase();
|
|
116
|
+
}
|
|
117
|
+
/** Every word of the query is somewhere in the service's name or group. */
|
|
118
|
+
function matchesQuery(service, query, groupLabel) {
|
|
119
|
+
const haystack = fold(`${service.name} ${groupLabel}`);
|
|
120
|
+
return fold(query)
|
|
121
|
+
.split(/\s+/)
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
.every((word) => haystack.includes(word));
|
|
124
|
+
}
|
|
125
|
+
function searchReady(labels) {
|
|
126
|
+
return SERVICE_SCREEN_SEARCH_LABEL_KEYS.every((key) => labels[key] !== undefined);
|
|
127
|
+
}
|
|
128
|
+
/** The search field, and the sentence for an empty result under it. */
|
|
129
|
+
function ServiceSearch({ id, query, onQuery, empty, labels, }) {
|
|
130
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "relative", children: [_jsx(Search, { "aria-hidden": "true", className: "pointer-events-none absolute start-4 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" }), _jsx("input", { id: id, type: "search", value: query, onChange: (event) => onQuery(event.target.value), "aria-label": labelText(labels['service.searchLabel']), placeholder: labelText(labels['service.search']), autoComplete: "off", enterKeyHint: "search",
|
|
131
|
+
// 16 px: a smaller field makes iOS zoom the page on focus.
|
|
132
|
+
className: "h-12 w-full rounded-lg border border-border bg-card ps-11 pe-4 text-base outline-none focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary" })] }), _jsx("p", { "aria-live": "polite", className: "text-sm text-muted-foreground empty:hidden", children: empty && query.trim() !== ''
|
|
133
|
+
? fillLabel(labels['service.searchEmpty'], { query: query.trim() })
|
|
134
|
+
: '' })] }));
|
|
135
|
+
}
|
|
98
136
|
export function ServiceScreen(props) {
|
|
99
137
|
const shared = {
|
|
100
138
|
labels: props.labels,
|
|
@@ -126,8 +164,10 @@ function UnlikelyForAge({ id, name, services, shared, children, }) {
|
|
|
126
164
|
return null;
|
|
127
165
|
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))) })] }));
|
|
128
166
|
}
|
|
129
|
-
function SingleServiceScreen({ services, categories, categoryOf, serviceFits = allFit, phone = null, initialCategory = null, onPick, suggestion = null, chosenId = null, childName = null, selection, shared, }) {
|
|
167
|
+
function SingleServiceScreen({ services, categories, categoryOf, serviceFits = allFit, phone = null, initialCategory = null, onPick, suggestion = null, chosenId = null, childName = null, selection, searchFor, shared, }) {
|
|
130
168
|
const { Card, labels, classNames } = shared;
|
|
169
|
+
const searchable = searchFor?.(0) === true && searchReady(labels);
|
|
170
|
+
const [query, setQuery] = useState('');
|
|
131
171
|
const ticked = selection ? tickedBy(selection, 0) : null;
|
|
132
172
|
// One person: a tap either answers the step or, multi-select, ticks.
|
|
133
173
|
const bookable = (service) => ticked && selection
|
|
@@ -141,7 +181,11 @@ function SingleServiceScreen({ services, categories, categoryOf, serviceFits = a
|
|
|
141
181
|
const fileUnder = categoryOf ?? defaultCategoryOf(categories);
|
|
142
182
|
// What certainly does not suit this person moves below a divider, still
|
|
143
183
|
// bookable. Telephone-only cards stay where they are: the list stays whole.
|
|
144
|
-
const
|
|
184
|
+
const labelOf = (service) => categories.find((category) => category.key === fileUnder(service))?.label ?? '';
|
|
185
|
+
const found = searchable && query.trim() !== ''
|
|
186
|
+
? services.filter((service) => matchesQuery(service, query, labelOf(service)))
|
|
187
|
+
: services;
|
|
188
|
+
const { fitting: offered, unlikely } = byFit(found, (service) => serviceFits(service, 0));
|
|
145
189
|
const groups = categories
|
|
146
190
|
.map((category) => ({
|
|
147
191
|
category,
|
|
@@ -168,7 +212,7 @@ function SingleServiceScreen({ services, categories, categoryOf, serviceFits = a
|
|
|
168
212
|
block: 'start',
|
|
169
213
|
});
|
|
170
214
|
}
|
|
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 }) => {
|
|
215
|
+
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'] })] })), searchable && (_jsx(ServiceSearch, { id: "booking-service-search", query: query, onQuery: setQuery, empty: offered.length === 0 && unlikely.length === 0, labels: labels })), groups.length > 1 && (_jsxs("fieldset", { className: "flex flex-wrap gap-2", children: [_jsx("legend", { className: "sr-only", children: labels['service.categoriesLegend'] }), groups.map(({ category }) => {
|
|
172
216
|
const marked = category.key === current;
|
|
173
217
|
return (_jsx(BookingButton, { variant: "outline", size: "sm", "aria-pressed": marked, onClick: () => jumpTo(category.key),
|
|
174
218
|
// Weight and ring carry the selection alongside the colour, so
|
|
@@ -197,8 +241,9 @@ function defaultCategoryOf(categories) {
|
|
|
197
241
|
* (`audience: 'any'`), and an adult everything but the children's — which is
|
|
198
242
|
* often nothing, and they are told so.
|
|
199
243
|
*/
|
|
200
|
-
function PartyServiceScreen({ services, categories, categoryOf, childCategory, serviceFits = allFit, party, selection, shared, }) {
|
|
244
|
+
function PartyServiceScreen({ services, categories, categoryOf, childCategory, serviceFits = allFit, party, selection, searchFor, shared, }) {
|
|
201
245
|
const { Card, labels, classNames } = shared;
|
|
246
|
+
const [queries, setQueries] = useState({});
|
|
202
247
|
const fileUnder = categoryOf ?? defaultCategoryOf(categories);
|
|
203
248
|
const keysFor = (audience) => new Set(categories.filter((group) => group.audience === audience).map((group) => group.key));
|
|
204
249
|
// Children's groups: the configured one plus any group marked 'child'.
|
|
@@ -213,11 +258,14 @@ function PartyServiceScreen({ services, categories, categoryOf, childCategory, s
|
|
|
213
258
|
// comes back from the caller as `selection.notice`.
|
|
214
259
|
const fits = services.filter((service) => service.bookableOnline && (selection !== undefined || service.maxPerBooking >= size));
|
|
215
260
|
function personBody(person, index) {
|
|
261
|
+
const searchable = searchFor?.(index) === true && searchReady(labels);
|
|
262
|
+
const query = searchable ? (queries[index] ?? '') : '';
|
|
263
|
+
const labelOf = (service) => categories.find((category) => category.key === fileUnder(service))?.label ?? '';
|
|
216
264
|
const { fitting: menu, unlikely } = byFit(
|
|
217
265
|
// A grown-up gets the grown-ups' menu and a child the children's —
|
|
218
266
|
// a kids' cut for «myself» is not an answer, it is a way round the
|
|
219
267
|
// note below that books the wrong thing.
|
|
220
|
-
childCategory === undefined
|
|
268
|
+
(childCategory === undefined
|
|
221
269
|
? fits
|
|
222
270
|
: fits.filter((service) => {
|
|
223
271
|
const key = fileUnder(service);
|
|
@@ -229,7 +277,8 @@ function PartyServiceScreen({ services, categories, categoryOf, childCategory, s
|
|
|
229
277
|
// built-in display fallback, which files an unlisted (possibly
|
|
230
278
|
// grown-up) category under the last group.
|
|
231
279
|
return person.adult || forAnyone.has(categoryOf ? key : service.category);
|
|
232
|
-
}), (service) => serviceFits(service, index));
|
|
280
|
+
})).filter((service) => query.trim() === '' || matchesQuery(service, query, labelOf(service))), (service) => serviceFits(service, index));
|
|
281
|
+
const search = searchable && (_jsx(ServiceSearch, { id: `booking-service-search-${index}`, query: query, onQuery: (next) => setQueries((all) => ({ ...all, [index]: next })), empty: menu.length === 0 && unlikely.length === 0, labels: labels }));
|
|
233
282
|
const ticked = selection ? tickedBy(selection, index) : null;
|
|
234
283
|
const chosen = party.choices[index]?.id ?? null;
|
|
235
284
|
const suggestion = person.suggestion ?? null;
|
|
@@ -244,14 +293,15 @@ function PartyServiceScreen({ services, categories, categoryOf, childCategory, s
|
|
|
244
293
|
const unlikelyList = (_jsx(UnlikelyForAge, { id: `booking-service-unlikely-${index}`, name: person.name, services: unlikely, shared: shared, children: choiceCard }));
|
|
245
294
|
const listLabel = fillLabel(labels['service.party.listLabel'], { label: person.label });
|
|
246
295
|
if (suggestion && menu.some((service) => service.id === suggestion.service.id)) {
|
|
247
|
-
return (_jsx(PersonWithSuggestion
|
|
248
|
-
|
|
249
|
-
|
|
296
|
+
return (_jsxs(_Fragment, { children: [search, _jsx(PersonWithSuggestion
|
|
297
|
+
// A fresh disclosure per person: the tabs share one panel.
|
|
298
|
+
, { listLabel: listLabel, suggestion: suggestion, menu: menu, ticked: ticked, chosen: chosen, onPick: pick, choiceCard: choiceCard, unlikely: unlikelyList, hasUnlikely: unlikely.length > 0, shared: shared }, person.key)] }));
|
|
250
299
|
}
|
|
251
|
-
|
|
300
|
+
// Nothing matching a search is the search's to say, not «nothing fits».
|
|
301
|
+
if (menu.length === 0 && unlikely.length === 0 && query.trim() === '') {
|
|
252
302
|
return (_jsx(NothingInThisParty, { person: person, labels: labels, multiLabels: selection?.labels ?? null, onRemove: party.onRemove ? () => party.onRemove?.(index) : undefined }));
|
|
253
303
|
}
|
|
254
|
-
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] }));
|
|
304
|
+
return (_jsxs(_Fragment, { children: [search, menu.length > 0 && (_jsx("ul", { "aria-label": listLabel, className: "space-y-2", children: menu.map((service) => (_jsx("li", { children: choiceCard(service) }, service.id))) })), unlikelyList] }));
|
|
255
305
|
}
|
|
256
306
|
if (selection) {
|
|
257
307
|
// A tab that left the party falls back to the last one still in it.
|
|
@@ -40,12 +40,10 @@ export function SummaryBar({ line, detail, canAdvance, step, onNext, hideNextWhe
|
|
|
40
40
|
// bottom of a phone is the outcome to avoid.
|
|
41
41
|
if (step === 'details')
|
|
42
42
|
return null;
|
|
43
|
+
const placeholder = step === 'who' ? labels['summary.placeholder.who'] : labels['summary.placeholder.service'];
|
|
43
44
|
const hintKey = HINT_KEYS[step];
|
|
44
45
|
const hint = hideNextWhenDisabled && !canAdvance && hintKey ? labels[hintKey] : undefined;
|
|
45
46
|
// There from the first step at one fixed height, rather than appearing on
|
|
46
47
|
// the first tap and pushing the page down under the visitor's thumb.
|
|
47
|
-
return (_jsxs("div", { className: slotClass(classNames, 'root', 'sticky bottom-0 z-40 flex h-16 items-center justify-between gap-4 border-t border-border bg-background/95 px-5 backdrop-blur-md'), children: [_jsxs("div", { className: "flex min-w-0 flex-col", children: [_jsx("p", { className: slotClass(classNames, 'line', 'min-w-0 truncate text-sm font-medium', !line && 'text-muted-foreground'), children: line ||
|
|
48
|
-
(step === 'who'
|
|
49
|
-
? labels['summary.placeholder.who']
|
|
50
|
-
: labels['summary.placeholder.service']) }), line && detail && (_jsx("p", { className: slotClass(classNames, 'detail', 'min-w-0 truncate text-xs text-muted-foreground tabular-nums'), children: detail }))] }), hideNextWhenDisabled && !canAdvance ? (hint && (_jsx("p", { className: slotClass(classNames, 'hint', 'shrink-0 text-sm font-medium text-muted-foreground'), children: renderLabel(hint) }))) : (_jsx(BookingButton, { size: "lg", className: slotClass(classNames, 'next'), onClick: onNext, disabled: !canAdvance, children: labels['summary.next'] }))] }));
|
|
48
|
+
return (_jsxs("div", { className: slotClass(classNames, 'root', 'sticky bottom-0 z-40 flex h-16 items-center justify-between gap-4 border-t border-border bg-background/95 px-5 backdrop-blur-md'), children: [_jsxs("div", { className: "flex min-w-0 flex-col", children: [_jsx("p", { className: slotClass(classNames, 'line', 'min-w-0 truncate text-sm font-medium', !line && 'text-muted-foreground'), children: line || (hint ? null : placeholder) }), line && detail && (_jsx("p", { className: slotClass(classNames, 'detail', 'min-w-0 truncate text-xs text-muted-foreground tabular-nums'), children: detail }))] }), hideNextWhenDisabled && !canAdvance ? (hint && (_jsx("p", { className: slotClass(classNames, 'hint', 'shrink-0 text-sm font-medium text-muted-foreground'), children: renderLabel(hint) }))) : (_jsx(BookingButton, { size: "lg", className: slotClass(classNames, 'next'), onClick: onNext, disabled: !canAdvance, children: labels['summary.next'] }))] }));
|
|
51
49
|
}
|
|
@@ -41,7 +41,7 @@ export declare const WHO_SCREEN_LABEL_KEYS: readonly ["who.heading", "who.guest.
|
|
|
41
41
|
* stepper buttons' accessible names; `who.party.count` `{count}` is read out
|
|
42
42
|
* when the number changes.
|
|
43
43
|
*/
|
|
44
|
-
export declare const WHO_SCREEN_PARTY_LABEL_KEYS: readonly ["who.party.children", "who.party.childrenNote", "who.party.adult", "who.party.adultNote", "who.party.fewer", "who.party.more", "who.party.count"];
|
|
44
|
+
export declare const WHO_SCREEN_PARTY_LABEL_KEYS: readonly ["who.party.children", "who.party.childrenNote", "who.party.adult", "who.party.adultNote", "who.party.fewer", "who.party.more", "who.party.count", "who.party.adultFewer", "who.party.adultMore", "who.party.adultCount", "who.party.lead", "who.party.togetherHint"];
|
|
45
45
|
export type WhoScreenLabels = Record<(typeof WHO_SCREEN_LABEL_KEYS)[number], BookingLabel> & Partial<Record<(typeof WHO_SCREEN_PARTY_LABEL_KEYS)[number], BookingLabel>> & AddChildSheetLabels;
|
|
46
46
|
/**
|
|
47
47
|
* - `root` — the `<section>`
|
|
@@ -138,6 +138,15 @@ export interface WhoScreenProps {
|
|
|
138
138
|
* answer. Default `false`.
|
|
139
139
|
*/
|
|
140
140
|
startEmpty?: boolean;
|
|
141
|
+
/**
|
|
142
|
+
* The grown-ups' seats, 1-based, when «Voksen» is a number (a stepper)
|
|
143
|
+
* rather than one check row: two parents, or a parent and a grandparent,
|
|
144
|
+
* in one booking. Seat 1 must be `adult`. Needs the `who.party.adultFewer`,
|
|
145
|
+
* `adultMore` and `adultCount` labels; without them the check row stays.
|
|
146
|
+
*/
|
|
147
|
+
adults?: (seat: number) => WizardPerson & {
|
|
148
|
+
adult: true;
|
|
149
|
+
};
|
|
141
150
|
};
|
|
142
151
|
/** Shown under the heading for a guest — e.g. an offer to log in. */
|
|
143
152
|
loginRow?: ReactNode;
|
|
@@ -62,12 +62,26 @@ export const WHO_SCREEN_PARTY_LABEL_KEYS = [
|
|
|
62
62
|
'who.party.childrenNote',
|
|
63
63
|
/** The grown-up's row, e.g. «I'm getting a cut too». */
|
|
64
64
|
'who.party.adult',
|
|
65
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* Under it. Check row: about sitting beside a child («Side by side, at the
|
|
67
|
+
* same time»), so shown only while there is one. Stepper
|
|
68
|
+
* (`guestParty.adults`): it describes the grown-ups' row the way
|
|
69
|
+
* `childrenNote` does the children's («Cut, beard, colour»), so always.
|
|
70
|
+
*/
|
|
66
71
|
'who.party.adultNote',
|
|
67
72
|
'who.party.fewer',
|
|
68
73
|
'who.party.more',
|
|
69
74
|
/** `{count}` — the children's number, read out, e.g. «{count} children». */
|
|
70
75
|
'who.party.count',
|
|
76
|
+
/** With `guestParty.adults`: the grown-ups' stepper buttons and their number read out. */
|
|
77
|
+
'who.party.adultFewer',
|
|
78
|
+
'who.party.adultMore',
|
|
79
|
+
/** `{count}` — e.g. «{count} grown-ups». */
|
|
80
|
+
'who.party.adultCount',
|
|
81
|
+
/** Under the heading, e.g. «Up to three in one booking». */
|
|
82
|
+
'who.party.lead',
|
|
83
|
+
/** Under the steppers while there are children and no grown-up. */
|
|
84
|
+
'who.party.togetherHint',
|
|
71
85
|
];
|
|
72
86
|
/** The `who.party.*` keys `guestParty` cannot draw without; the two notes are extras. */
|
|
73
87
|
const PARTY_REQUIRED_LABEL_KEYS = [
|
|
@@ -146,11 +160,19 @@ export function WhoScreen({ labels, format, people, family, guestChoices, addedC
|
|
|
146
160
|
* booking for themselves is one tap like anyone else.
|
|
147
161
|
*/
|
|
148
162
|
function GuestParty({ party, people, maxPeople, labels, classNames, onChoose, }) {
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
163
|
+
const adultStepper = party.adults !== undefined &&
|
|
164
|
+
labels['who.party.adultFewer'] !== undefined &&
|
|
165
|
+
labels['who.party.adultMore'] !== undefined &&
|
|
166
|
+
labels['who.party.adultCount'] !== undefined;
|
|
167
|
+
const adultSeat = (seat) => seat === 1 || !party.adults ? party.adult : party.adults(seat);
|
|
168
|
+
const adultKeys = new Set(Array.from({ length: maxPeople }, (_, index) => adultSeat(index + 1).key));
|
|
169
|
+
const isAdultSeat = (person) => adultKeys.has(person.key);
|
|
170
|
+
const adults = people.filter(isAdultSeat).length;
|
|
171
|
+
const adultOn = adults > 0;
|
|
172
|
+
// A grown-up's seat is never a child, flag or no flag.
|
|
173
|
+
const childSeats = people.filter((person) => !person.adult && !isAdultSeat(person));
|
|
152
174
|
const children = childSeats.length;
|
|
153
|
-
const full = children +
|
|
175
|
+
const full = children + adults >= maxPeople;
|
|
154
176
|
// Named children (from the add-child sheet) keep their seats, first; the
|
|
155
177
|
// stepper adds and removes the unnamed ones after them.
|
|
156
178
|
const generatedKeys = new Set(Array.from({ length: maxPeople }, (_, index) => party.child(index + 1).key));
|
|
@@ -159,7 +181,8 @@ function GuestParty({ party, people, maxPeople, labels, classNames, onChoose, })
|
|
|
159
181
|
// The stepper never takes a named child away: their own card does that.
|
|
160
182
|
// Without `startEmpty` it never empties the booking either.
|
|
161
183
|
const floor = Math.max(named.length, adultOn || startEmpty ? 0 : 1);
|
|
162
|
-
const
|
|
184
|
+
const adultFloor = children === 0 && !startEmpty ? 1 : 0;
|
|
185
|
+
const answer = (count, grownUps) => {
|
|
163
186
|
const kept = named;
|
|
164
187
|
const used = new Set(kept.map((person) => person.key));
|
|
165
188
|
const fill = [];
|
|
@@ -168,7 +191,8 @@ function GuestParty({ party, people, maxPeople, labels, classNames, onChoose, })
|
|
|
168
191
|
if (!used.has(next.key))
|
|
169
192
|
fill.push(next);
|
|
170
193
|
}
|
|
171
|
-
|
|
194
|
+
const seated = Array.from({ length: grownUps }, (_, index) => adultSeat(index + 1));
|
|
195
|
+
onChoose([...kept, ...fill, ...seated], false);
|
|
172
196
|
};
|
|
173
197
|
// One child whenever the answer becomes empty (first frame or cleared
|
|
174
198
|
// later): an empty answer would leave «next» dead on the most common visit.
|
|
@@ -178,7 +202,7 @@ function GuestParty({ party, people, maxPeople, labels, classNames, onChoose, })
|
|
|
178
202
|
useEffect(() => {
|
|
179
203
|
const empty = people.length === 0;
|
|
180
204
|
if (empty && !wasEmpty.current && !startEmpty)
|
|
181
|
-
answer(1,
|
|
205
|
+
answer(1, 0);
|
|
182
206
|
// An empty answer `startEmpty` left alone is not one already seen: if the
|
|
183
207
|
// flag goes, the child is still filled in.
|
|
184
208
|
wasEmpty.current = empty && !startEmpty;
|
|
@@ -187,11 +211,17 @@ function GuestParty({ party, people, maxPeople, labels, classNames, onChoose, })
|
|
|
187
211
|
const label = labels[key];
|
|
188
212
|
return label ? fillLabel(label, values) : '';
|
|
189
213
|
};
|
|
190
|
-
const
|
|
191
|
-
|
|
214
|
+
const childrenNote = labels['who.party.childrenNote'];
|
|
215
|
+
const adultNote = labels['who.party.adultNote'];
|
|
216
|
+
return (_jsxs("div", { className: "space-y-3", children: [labels['who.party.lead'] && (_jsx("p", { className: "text-sm text-muted-foreground", children: renderLabel(labels['who.party.lead']) })), _jsxs("div", { className: cn('divide-y divide-border rounded-lg border border-border bg-card', classNames?.card), children: [_jsx(StepperRow, { title: text('who.party.children'), note: childrenNote ? renderLabel(childrenNote) : null, value: children, countLabel: text('who.party.count', { count: children }), fewerLabel: text('who.party.fewer'), moreLabel: text('who.party.more'), canFewer: children > floor, canMore: !full, limitId: full ? LIMIT_ID : undefined, onFewer: () => answer(children - 1, adults), onMore: () => answer(children + 1, adults) }), adultStepper && (_jsx(StepperRow, { title: text('who.party.adult'), note: adultNote ? renderLabel(adultNote) : null, value: adults, countLabel: text('who.party.adultCount', { count: adults }), fewerLabel: text('who.party.adultFewer'), moreLabel: text('who.party.adultMore'), canFewer: adults > adultFloor, canMore: !full, limitId: full ? LIMIT_ID : undefined, onFewer: () => answer(children, adults - 1), onMore: () => answer(children, adults + 1) }))] }), !adultStepper && (_jsxs(CheckRow, { checked: adultOn,
|
|
192
217
|
// Never down to nobody: the last person in the booking stays — unless
|
|
193
218
|
// the booking may start (and so stand) empty.
|
|
194
|
-
disabled: (!adultOn && full) || (adultOn && children === 0 && !startEmpty), describedBy: !adultOn && full ? LIMIT_ID : undefined, onToggle: () => answer(children,
|
|
219
|
+
disabled: (!adultOn && full) || (adultOn && children === 0 && !startEmpty), describedBy: !adultOn && full ? LIMIT_ID : undefined, onToggle: () => answer(children, adultOn ? 0 : 1), className: cn('min-h-14 justify-between px-5 py-3 text-sm', classNames?.card), selectedClassName: classNames?.cardSelected, children: [_jsxs("span", { className: "flex min-w-0 flex-col text-left", children: [_jsx("span", { className: "font-medium", children: text('who.party.adult') }), adultNote && children > 0 && (_jsx("span", { className: "text-muted-foreground", children: renderLabel(adultNote) }))] }), _jsx(CheckCircle, { checked: adultOn })] })), labels['who.party.togetherHint'] && children > 0 && !adultOn && !full && (_jsx("p", { className: "text-sm text-muted-foreground", children: renderLabel(labels['who.party.togetherHint']) })), _jsx("p", { id: LIMIT_ID, "aria-live": "polite", className: slotClass(classNames, 'limit', 'min-h-5 text-sm text-muted-foreground'), children: full ? renderLabel(labels['who.family.limit'], { max: maxPeople }) : '' })] }));
|
|
220
|
+
}
|
|
221
|
+
const STEP_BUTTON = 'flex size-11 shrink-0 items-center justify-center rounded-full border border-border bg-background text-foreground transition-colors hover:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-40';
|
|
222
|
+
/** One «name, note, − n +» row of a guest party. */
|
|
223
|
+
function StepperRow({ title, note, value, countLabel, fewerLabel, moreLabel, canFewer, canMore, limitId, onFewer, onMore, }) {
|
|
224
|
+
return (_jsxs("div", { className: "flex items-center gap-4 px-5 py-4", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "font-semibold", children: title }), note !== null && _jsx("p", { className: "text-sm text-muted-foreground", children: note })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("button", { type: "button", "aria-label": fewerLabel, disabled: !canFewer, onClick: onFewer, className: STEP_BUTTON, children: _jsx(Minus, { "aria-hidden": "true", className: "size-4" }) }), _jsxs("span", { "aria-live": "polite", "aria-atomic": "true", className: "w-6 text-center text-lg font-bold tabular-nums", children: [_jsx("span", { "aria-hidden": "true", children: value }), _jsx("span", { className: "sr-only", children: countLabel })] }), _jsx("button", { type: "button", "aria-label": moreLabel, "aria-describedby": limitId, disabled: !canMore, onClick: onMore, className: STEP_BUTTON, children: _jsx(Plus, { "aria-hidden": "true", className: "size-4" }) })] })] }));
|
|
195
225
|
}
|
|
196
226
|
/**
|
|
197
227
|
* The guest chips as a radio group. Roving focus: one chip is in the tab
|