@medalsocial/meda 3.9.0 → 3.10.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.
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ComponentType } from 'react';
|
|
1
|
+
import { type ComponentType, type ReactNode } from 'react';
|
|
2
2
|
import type { BookingDaypart, BookingFormat } from './format.js';
|
|
3
3
|
import { type BookingLabel } from './labels.js';
|
|
4
4
|
import { type SlotClassNames } from './slots.js';
|
|
@@ -70,8 +70,23 @@ export declare function dayFreeMarks(free: number): 0 | 1 | 2 | 3;
|
|
|
70
70
|
export declare function DefaultDayChip({ label, selected, className, onSelect, free, freeLabel, }: DayChipProps): import("react").JSX.Element;
|
|
71
71
|
export interface TimeChipProps {
|
|
72
72
|
startTs: number;
|
|
73
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* «14:30», a party's «15:00 → 16:00», a simultaneous party's
|
|
75
|
+
* «15:00 (Mia with Ada · Leo with Bo)», or the current hour's
|
|
76
|
+
* «15:00 · Current time». Always the chip's whole sentence.
|
|
77
|
+
*/
|
|
74
78
|
label: string;
|
|
79
|
+
/**
|
|
80
|
+
* `label` as elements, in the label pack's own order, with a simultaneous
|
|
81
|
+
* party's seating in a span that may wrap — so a long seating breaks onto
|
|
82
|
+
* the next line instead of widening the chip past the screen.
|
|
83
|
+
*/
|
|
84
|
+
richLabel?: ReactNode;
|
|
85
|
+
/**
|
|
86
|
+
* What a week-grid column shows: too narrow for the seating, so only the
|
|
87
|
+
* hour, with `label` as the accessible name and the tooltip.
|
|
88
|
+
*/
|
|
89
|
+
compactLabel?: string;
|
|
75
90
|
/** One of the nearest alternatives to a slot that was just taken. */
|
|
76
91
|
nearest: boolean;
|
|
77
92
|
/** The screen-reader text that says so. */
|
|
@@ -83,7 +98,7 @@ export interface TimeChipProps {
|
|
|
83
98
|
onPick: () => void;
|
|
84
99
|
}
|
|
85
100
|
/** One time chip. */
|
|
86
|
-
export declare function DefaultTimeChip({ label, nearest, nearestLabel, current, className, onPick, }: TimeChipProps): import("react").JSX.Element;
|
|
101
|
+
export declare function DefaultTimeChip({ label, richLabel, compactLabel, nearest, nearestLabel, current, className, onPick, }: TimeChipProps): import("react").JSX.Element;
|
|
87
102
|
export interface TimeScreenComponents {
|
|
88
103
|
DayChip?: ComponentType<DayChipProps>;
|
|
89
104
|
TimeChip?: ComponentType<TimeChipProps>;
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
3
3
|
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, X } from 'lucide-react';
|
|
4
4
|
import { useEffect, useId, useRef, useState } from 'react';
|
|
5
5
|
import { cn } from '../lib/utils.js';
|
|
6
|
-
import { renderLabel } from './internal/label-parts.js';
|
|
6
|
+
import { labelParts, renderLabel } from './internal/label-parts.js';
|
|
7
7
|
import { BookingButton, bookingButtonClass } from './internal/ui.js';
|
|
8
8
|
import { visitName } from './internal/visit.js';
|
|
9
9
|
import { fillLabel, labelText } from './labels.js';
|
|
@@ -123,11 +123,13 @@ export function DefaultDayChip({ label, selected, className, onSelect, free, fre
|
|
|
123
123
|
return (_jsxs(BookingButton, { variant: "outline", size: "sm", "aria-pressed": selected, onClick: onSelect, className: cn('h-auto min-h-11 flex-col gap-1 py-1.5', free === 0 && !selected && 'text-muted-foreground', className), children: [_jsx("span", { children: label }), free === 0 && freeLabel ? (_jsx("span", { className: "text-[11px] font-medium leading-none", children: freeLabel })) : (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", "data-testid": "day-free-marks", className: "flex gap-0.5", children: [1, 2, 3].map((mark) => (_jsx("span", { "data-filled": mark <= marks ? '' : undefined, className: cn('block h-[3px] w-2 rounded-full bg-current', mark > marks && 'opacity-25') }, mark))) }), freeLabel && _jsx("span", { className: "sr-only", children: freeLabel })] }))] }));
|
|
124
124
|
}
|
|
125
125
|
/** One time chip. */
|
|
126
|
-
export function DefaultTimeChip({ label, nearest, nearestLabel, current, className, onPick, }) {
|
|
126
|
+
export function DefaultTimeChip({ label, richLabel, compactLabel, nearest, nearestLabel, current, className, onPick, }) {
|
|
127
127
|
if (current) {
|
|
128
128
|
return (_jsx(BookingButton, { variant: "outline", size: "lg", disabled: true, "aria-current": "true", className: className, children: label }));
|
|
129
129
|
}
|
|
130
|
-
|
|
130
|
+
// A compact chip draws less than its sentence, so the sentence becomes its name.
|
|
131
|
+
const compact = compactLabel !== undefined;
|
|
132
|
+
return (_jsxs(BookingButton, { variant: "outline", size: "lg", onClick: onPick, "aria-label": compact ? `${label}${nearest ? ` ${nearestLabel}` : ''}` : undefined, title: compact ? label : undefined, className: cn(!compact && richLabel !== undefined && 'h-auto max-w-full py-2 whitespace-normal', className), children: [compact && compactLabel, !compact && richLabel !== undefined && (_jsx("span", { className: "min-w-0 text-center", children: richLabel })), !compact && richLabel === undefined && label, nearest && !compact && (_jsxs("span", { className: "sr-only", children: [" ", nearestLabel] }))] }));
|
|
131
133
|
}
|
|
132
134
|
// ---------------------------------------------------------------------------
|
|
133
135
|
// View logic
|
|
@@ -174,16 +176,19 @@ function buildDayStrip(clock, slots, days, takenSlotTs, currentSlotTs, openDays,
|
|
|
174
176
|
/**
|
|
175
177
|
* The one chip a family taps: `15:00 → 16:00`, or the parallel form
|
|
176
178
|
* `15:00 (Name with Stylist · Name with Stylist)`. The parallel chip names the
|
|
177
|
-
* people where the visitor has given a name and the service otherwise
|
|
179
|
+
* people where the visitor has given a name and the service otherwise; its
|
|
180
|
+
* seating is drawn so it can wrap, and a week column shows the hour alone.
|
|
178
181
|
*/
|
|
179
|
-
function
|
|
182
|
+
function partyChipText(labels, clock, slot, party) {
|
|
180
183
|
if (slot.mode === 'sequential') {
|
|
181
|
-
return
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
184
|
+
return {
|
|
185
|
+
label: fillLabel(labels['time.party.sequential'], {
|
|
186
|
+
start: clock.formatTime(slot.startTs),
|
|
187
|
+
end: clock.formatTime(party.chipEndTs(slot)),
|
|
188
|
+
}),
|
|
189
|
+
};
|
|
185
190
|
}
|
|
186
|
-
const
|
|
191
|
+
const names = slot.seats.map((seat, index) => {
|
|
187
192
|
const item = party.items[index];
|
|
188
193
|
const who = item?.bookedForName ?? (item ? visitName(item) : '');
|
|
189
194
|
const stylist = party.resolveStylistName(seat.resourceId);
|
|
@@ -191,10 +196,16 @@ function partyChipLabel(labels, clock, slot, party) {
|
|
|
191
196
|
// is a reload of the resource list, not a reason to hide the slot.
|
|
192
197
|
return stylist === null ? who : fillLabel(labels['time.party.seat'], { who, stylist });
|
|
193
198
|
});
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
199
|
+
const time = clock.formatTime(slot.startTs);
|
|
200
|
+
const seats = names.join(labelText(labels['time.party.seatSeparator']));
|
|
201
|
+
return {
|
|
202
|
+
label: fillLabel(labels['time.party.parallel'], { time, seats }),
|
|
203
|
+
richLabel: labelParts(labels['time.party.parallel'], {
|
|
204
|
+
time,
|
|
205
|
+
seats: _jsx("span", { className: "font-normal text-muted-foreground", children: seats }),
|
|
206
|
+
}),
|
|
207
|
+
compactLabel: time,
|
|
208
|
+
};
|
|
198
209
|
}
|
|
199
210
|
/**
|
|
200
211
|
* The instants on offer — ONE per instant. Availability is per (start,
|
|
@@ -426,7 +437,7 @@ function WeekGrid({ labels, words, format, days, offered, currentSlotTs, nearest
|
|
|
426
437
|
const partySlot = partyByStart.get(slot.startTs);
|
|
427
438
|
const current = slot.startTs === currentSlotTs;
|
|
428
439
|
const isNearest = !current && nearest.has(slot.startTs);
|
|
429
|
-
return (_jsx("li", { children: _jsx(TimeChip, { startTs: slot.startTs,
|
|
440
|
+
return (_jsx("li", { children: _jsx(TimeChip, { startTs: slot.startTs, ...chipText(labels, clock, slot, current, partySlot, party), nearest: isNearest, nearestLabel: labelText(labels['time.nearest']), current: current, onPick: () => (partySlot && party ? party.onPick(partySlot) : onPick(slot)), className: cn('h-auto min-h-11 w-full min-w-0 whitespace-normal px-1 tabular-nums', isNearest ? 'border-primary ring-2 ring-primary' : '', classNames?.chip, isNearest ? classNames?.chipNearest : undefined, current ? classNames?.chipCurrent : undefined) }) }, slot.startTs));
|
|
430
441
|
}) })), more > 0 && (_jsx(BookingButton, { variant: "link", size: "sm", onClick: () => setUnfolded(key), className: "w-full", children: renderLabel(words.more, { count: more }) })), bookable === 0 &&
|
|
431
442
|
(alternative && party ? (_jsx(BookingButton, { size: "sm", onClick: () => party.onPick(alternative), className: "h-auto min-h-11 w-full whitespace-normal tabular-nums", children: renderLabel(labels['time.parallel.accept'], {
|
|
432
443
|
time: clock.formatTime(alternative.startTs),
|
|
@@ -435,13 +446,13 @@ function WeekGrid({ labels, words, format, days, offered, currentSlotTs, nearest
|
|
|
435
446
|
return (_jsxs("div", { "data-testid": "time-week", className: cn('space-y-4', className), children: [_jsx("ol", { className: "grid grid-cols-7 gap-2", children: columns }), !anyBookable && empty, next, todayOpen && (_jsx(ShortNoticeLine, { labels: labels, format: format, phone: phone, className: classNames?.shortNotice }))] }));
|
|
436
447
|
}
|
|
437
448
|
/** A time chip's words: the hour, the held hour marked, or a family's whole visit. */
|
|
438
|
-
function
|
|
449
|
+
function chipText(labels, clock, slot, current, partySlot, party) {
|
|
439
450
|
const time = clock.formatTime(slot.startTs);
|
|
440
451
|
if (current)
|
|
441
|
-
return fillLabel(labels['time.current'], { time });
|
|
452
|
+
return { label: fillLabel(labels['time.current'], { time }) };
|
|
442
453
|
if (partySlot && party)
|
|
443
|
-
return
|
|
444
|
-
return time;
|
|
454
|
+
return partyChipText(labels, clock, partySlot, party);
|
|
455
|
+
return { label: time };
|
|
445
456
|
}
|
|
446
457
|
/** The weekend note as one plain sentence, for a card that books without the notice on screen. */
|
|
447
458
|
function weekendSentenceText(labels, format, note) {
|
|
@@ -594,7 +605,7 @@ function DaypartSections({ labels, clock, dayparts, daySlots, nearest, currentSl
|
|
|
594
605
|
const partySlot = partyByStart.get(slot.startTs);
|
|
595
606
|
const current = slot.startTs === currentSlotTs;
|
|
596
607
|
const isNearest = !current && nearest.has(slot.startTs);
|
|
597
|
-
return (_jsx(TimeChip, { startTs: slot.startTs,
|
|
608
|
+
return (_jsx(TimeChip, { startTs: slot.startTs, ...chipText(labels, clock, slot, current, partySlot, party), compactLabel: undefined, 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));
|
|
598
609
|
}) })] }, daypart.key));
|
|
599
610
|
});
|
|
600
611
|
}
|
|
@@ -131,6 +131,13 @@ export interface WhoScreenProps {
|
|
|
131
131
|
adult: WizardPerson & {
|
|
132
132
|
adult: true;
|
|
133
133
|
};
|
|
134
|
+
/**
|
|
135
|
+
* Start with nobody, rather than one child: for a salon where a grown-up
|
|
136
|
+
* booking for themselves is as common as a child. Children go down to 0
|
|
137
|
+
* and the grown-up's row is a free choice either way; «next» waits for an
|
|
138
|
+
* answer. Default `false`.
|
|
139
|
+
*/
|
|
140
|
+
startEmpty?: boolean;
|
|
134
141
|
};
|
|
135
142
|
/** Shown under the heading for a guest — e.g. an offer to log in. */
|
|
136
143
|
loginRow?: ReactNode;
|
|
@@ -142,7 +142,8 @@ export function WhoScreen({ labels, format, people, family, guestChoices, addedC
|
|
|
142
142
|
* A guest's party: how many children (a stepper) and whether the grown-up
|
|
143
143
|
* comes too (a check row), so «a child and me» is one booking without an
|
|
144
144
|
* account. Starts at one child, which is the common answer, so «next» is live
|
|
145
|
-
* from the first frame
|
|
145
|
+
* from the first frame — or, with `startEmpty`, at nobody, so a grown-up
|
|
146
|
+
* booking for themselves is one tap like anyone else.
|
|
146
147
|
*/
|
|
147
148
|
function GuestParty({ party, people, maxPeople, labels, classNames, onChoose, }) {
|
|
148
149
|
const adultOn = people.some((person) => person.key === party.adult.key);
|
|
@@ -154,8 +155,10 @@ function GuestParty({ party, people, maxPeople, labels, classNames, onChoose, })
|
|
|
154
155
|
// stepper adds and removes the unnamed ones after them.
|
|
155
156
|
const generatedKeys = new Set(Array.from({ length: maxPeople }, (_, index) => party.child(index + 1).key));
|
|
156
157
|
const named = childSeats.filter((person) => !generatedKeys.has(person.key));
|
|
158
|
+
const startEmpty = party.startEmpty === true;
|
|
157
159
|
// The stepper never takes a named child away: their own card does that.
|
|
158
|
-
|
|
160
|
+
// Without `startEmpty` it never empties the booking either.
|
|
161
|
+
const floor = Math.max(named.length, adultOn || startEmpty ? 0 : 1);
|
|
159
162
|
const answer = (count, withAdult) => {
|
|
160
163
|
const kept = named;
|
|
161
164
|
const used = new Set(kept.map((person) => person.key));
|
|
@@ -174,9 +177,11 @@ function GuestParty({ party, people, maxPeople, labels, classNames, onChoose, })
|
|
|
174
177
|
const wasEmpty = useRef(false);
|
|
175
178
|
useEffect(() => {
|
|
176
179
|
const empty = people.length === 0;
|
|
177
|
-
if (empty && !wasEmpty.current)
|
|
180
|
+
if (empty && !wasEmpty.current && !startEmpty)
|
|
178
181
|
answer(1, false);
|
|
179
|
-
|
|
182
|
+
// An empty answer `startEmpty` left alone is not one already seen: if the
|
|
183
|
+
// flag goes, the child is still filled in.
|
|
184
|
+
wasEmpty.current = empty && !startEmpty;
|
|
180
185
|
});
|
|
181
186
|
const text = (key, values = {}) => {
|
|
182
187
|
const label = labels[key];
|
|
@@ -184,8 +189,9 @@ function GuestParty({ party, people, maxPeople, labels, classNames, onChoose, })
|
|
|
184
189
|
};
|
|
185
190
|
const stepButton = 'flex size-11 shrink-0 items-center justify-center rounded-full border border-border bg-background text-foreground transition-colors hover:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-40';
|
|
186
191
|
return (_jsxs("div", { className: "space-y-3", children: [_jsxs("div", { className: cn('flex items-center gap-4 rounded-lg border border-border bg-card px-5 py-4', classNames?.card), children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "font-semibold", children: text('who.party.children') }), labels['who.party.childrenNote'] && (_jsx("p", { className: "text-sm text-muted-foreground", children: renderLabel(labels['who.party.childrenNote']) }))] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("button", { type: "button", "aria-label": text('who.party.fewer'), disabled: children <= floor, onClick: () => answer(children - 1, adultOn), className: stepButton, children: _jsx(Minus, { "aria-hidden": "true", className: "size-4" }) }), _jsxs("span", { "aria-live": "polite", "aria-atomic": "true", className: "w-6 text-center text-lg font-bold tabular-nums", children: [_jsx("span", { "aria-hidden": "true", children: children }), _jsx("span", { className: "sr-only", children: text('who.party.count', { count: children }) })] }), _jsx("button", { type: "button", "aria-label": text('who.party.more'), "aria-describedby": full ? LIMIT_ID : undefined, disabled: full, onClick: () => answer(children + 1, adultOn), className: stepButton, children: _jsx(Plus, { "aria-hidden": "true", className: "size-4" }) })] })] }), _jsxs(CheckRow, { checked: adultOn,
|
|
187
|
-
// Never down to nobody: the last person in the booking stays
|
|
188
|
-
|
|
192
|
+
// Never down to nobody: the last person in the booking stays — unless
|
|
193
|
+
// 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, !adultOn), className: cn('min-h-14 justify-between px-5 py-3 text-sm', classNames?.card), selectedClassName: classNames?.cardSelected, children: [_jsxs("span", { className: "flex min-w-0 flex-col text-left", children: [_jsx("span", { className: "font-medium", children: text('who.party.adult') }), labels['who.party.adultNote'] && children > 0 && (_jsx("span", { className: "text-muted-foreground", children: renderLabel(labels['who.party.adultNote']) }))] }), _jsx(CheckCircle, { checked: adultOn })] }), _jsx("p", { id: LIMIT_ID, "aria-live": "polite", className: slotClass(classNames, 'limit', 'min-h-5 text-sm text-muted-foreground'), children: full ? renderLabel(labels['who.family.limit'], { max: maxPeople }) : '' })] }));
|
|
189
195
|
}
|
|
190
196
|
/**
|
|
191
197
|
* The guest chips as a radio group. Roving focus: one chip is in the tab
|