@medalsocial/meda 2.8.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -1
- package/dist/booking/add-child-sheet.d.ts +38 -0
- package/dist/booking/add-child-sheet.js +88 -0
- package/dist/booking/booking-skeleton.d.ts +24 -0
- package/dist/booking/booking-skeleton.js +17 -0
- package/dist/booking/confirmation.d.ts +85 -0
- package/dist/booking/confirmation.js +102 -0
- package/dist/booking/details-screen.d.ts +118 -0
- package/dist/booking/details-screen.js +314 -0
- package/dist/booking/format.d.ts +64 -0
- package/dist/booking/format.js +1 -0
- package/dist/booking/index.d.ts +35 -0
- package/dist/booking/index.js +40 -0
- package/dist/booking/internal/label-parts.d.ts +8 -0
- package/dist/booking/internal/label-parts.js +19 -0
- package/dist/booking/internal/ui.d.ts +33 -0
- package/dist/booking/internal/ui.js +35 -0
- package/dist/booking/label-keys.d.ts +34 -0
- package/dist/booking/label-keys.js +56 -0
- package/dist/booking/labels.d.ts +15 -0
- package/dist/booking/labels.js +17 -0
- package/dist/booking/live-status.d.ts +14 -0
- package/dist/booking/live-status.js +17 -0
- package/dist/booking/login-panel.d.ts +108 -0
- package/dist/booking/login-panel.js +239 -0
- package/dist/booking/login-sheet.d.ts +41 -0
- package/dist/booking/login-sheet.js +49 -0
- package/dist/booking/manage-screen.d.ts +113 -0
- package/dist/booking/manage-screen.js +369 -0
- package/dist/booking/otp-slots.d.ts +42 -0
- package/dist/booking/otp-slots.js +109 -0
- package/dist/booking/portal/account-card.d.ts +24 -0
- package/dist/booking/portal/account-card.js +18 -0
- package/dist/booking/portal/action-result.d.ts +30 -0
- package/dist/booking/portal/action-result.js +18 -0
- package/dist/booking/portal/age-confirm-card.d.ts +39 -0
- package/dist/booking/portal/age-confirm-card.js +42 -0
- package/dist/booking/portal/child-cards.d.ts +65 -0
- package/dist/booking/portal/child-cards.js +59 -0
- package/dist/booking/portal/data-controls.d.ts +48 -0
- package/dist/booking/portal/data-controls.js +103 -0
- package/dist/booking/portal/family-editor.d.ts +141 -0
- package/dist/booking/portal/family-editor.js +422 -0
- package/dist/booking/portal/logout-button.d.ts +21 -0
- package/dist/booking/portal/logout-button.js +27 -0
- package/dist/booking/portal/portal-shell.d.ts +71 -0
- package/dist/booking/portal/portal-shell.js +38 -0
- package/dist/booking/portal/portal-unreachable.d.ts +31 -0
- package/dist/booking/portal/portal-unreachable.js +19 -0
- package/dist/booking/portal/profile-form.d.ts +75 -0
- package/dist/booking/portal/profile-form.js +144 -0
- package/dist/booking/portal/rebook-cards.d.ts +44 -0
- package/dist/booking/portal/rebook-cards.js +35 -0
- package/dist/booking/portal/upcoming-bookings.d.ts +65 -0
- package/dist/booking/portal/upcoming-bookings.js +88 -0
- package/dist/booking/portal/vipps-link-row.d.ts +54 -0
- package/dist/booking/portal/vipps-link-row.js +80 -0
- package/dist/booking/portal/visit-history.d.ts +63 -0
- package/dist/booking/portal/visit-history.js +73 -0
- package/dist/booking/service-screen.d.ts +137 -0
- package/dist/booking/service-screen.js +233 -0
- package/dist/booking/slots.d.ts +12 -0
- package/dist/booking/slots.js +5 -0
- package/dist/booking/stylist-screen.d.ts +117 -0
- package/dist/booking/stylist-screen.js +226 -0
- package/dist/booking/summary-bar.d.ts +26 -0
- package/dist/booking/summary-bar.js +32 -0
- package/dist/booking/time-screen.d.ts +191 -0
- package/dist/booking/time-screen.js +418 -0
- package/dist/booking/types.d.ts +284 -0
- package/dist/booking/types.js +15 -0
- package/dist/booking/vipps-button.d.ts +49 -0
- package/dist/booking/vipps-button.js +33 -0
- package/dist/booking/who-screen.d.ts +121 -0
- package/dist/booking/who-screen.js +164 -0
- package/dist/primitives/avatar.d.ts +24 -0
- package/dist/primitives/avatar.js +44 -0
- package/dist/primitives/button.d.ts +21 -0
- package/dist/primitives/button.js +51 -0
- package/dist/primitives/checkbox.d.ts +23 -0
- package/dist/primitives/checkbox.js +20 -0
- package/dist/primitives/field.d.ts +47 -0
- package/dist/primitives/field.js +48 -0
- package/dist/primitives/index.d.ts +16 -0
- package/dist/primitives/index.js +8 -0
- package/dist/primitives/input.d.ts +10 -0
- package/dist/primitives/input.js +9 -0
- package/dist/primitives/sheet.d.ts +68 -0
- package/dist/primitives/sheet.js +303 -0
- package/dist/primitives/textarea.d.ts +14 -0
- package/dist/primitives/textarea.js +13 -0
- package/dist/primitives/toggle-group.d.ts +46 -0
- package/dist/primitives/toggle-group.js +148 -0
- package/dist/styles/base.css +40 -0
- package/dist/styles/booking.css +10 -0
- package/dist/styles/bridge.css +159 -0
- package/dist/styles/calendar.css +8 -0
- package/dist/styles/primitives.css +10 -0
- package/dist/styles/theme.css +12 -162
- package/dist/styles/vendor/xyflow.css +626 -0
- package/dist/styles/workflow-builder.css +17 -0
- package/package.json +45 -9
- package/skills/components/SKILL.md +53 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, X } from 'lucide-react';
|
|
4
|
+
import { useEffect, useState } from 'react';
|
|
5
|
+
import { cn } from '../lib/utils.js';
|
|
6
|
+
import { BookingButton, bookingButtonClass } from './internal/ui.js';
|
|
7
|
+
import { fillLabel } from './labels.js';
|
|
8
|
+
import { LiveStatus } from './live-status.js';
|
|
9
|
+
import { slotClass } from './slots.js';
|
|
10
|
+
/**
|
|
11
|
+
* The time step — «when suits you?».
|
|
12
|
+
*
|
|
13
|
+
* Presentational, with one piece of state the booking machine deliberately does
|
|
14
|
+
* not hold: which day the visitor is *looking at*. Nothing about it is
|
|
15
|
+
* submitted and it survives no further than this screen, so putting it in the
|
|
16
|
+
* machine would make the wizard remember a scroll position. What gets booked
|
|
17
|
+
* still leaves here as `onPick(slot)`.
|
|
18
|
+
*/
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Labels
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
export const TIME_SCREEN_LABEL_KEYS = [
|
|
23
|
+
'time.heading',
|
|
24
|
+
'time.dayStrip.legend',
|
|
25
|
+
'time.nearest',
|
|
26
|
+
'time.current',
|
|
27
|
+
'time.nextFree',
|
|
28
|
+
'time.shortNotice',
|
|
29
|
+
'time.weekend.pct',
|
|
30
|
+
'time.weekend.mixed',
|
|
31
|
+
'time.month.show',
|
|
32
|
+
'time.month.hide',
|
|
33
|
+
'time.month.previous',
|
|
34
|
+
'time.month.next',
|
|
35
|
+
'time.month.heading',
|
|
36
|
+
'time.month.note',
|
|
37
|
+
'time.party.sequential',
|
|
38
|
+
'time.party.parallel',
|
|
39
|
+
'time.party.seat',
|
|
40
|
+
'time.party.seatSeparator',
|
|
41
|
+
'time.parallel.offer.two',
|
|
42
|
+
'time.parallel.offer.other',
|
|
43
|
+
'time.parallel.accept',
|
|
44
|
+
'time.empty.closed',
|
|
45
|
+
'time.empty.over',
|
|
46
|
+
'time.empty.full',
|
|
47
|
+
'time.empty.stylist',
|
|
48
|
+
'time.empty.unknown',
|
|
49
|
+
'time.empty.callLink',
|
|
50
|
+
'time.empty.callLinkAria',
|
|
51
|
+
'time.empty.callSuffix',
|
|
52
|
+
'time.empty.callPlain',
|
|
53
|
+
'time.loading',
|
|
54
|
+
'time.skeleton.dayPlaceholder',
|
|
55
|
+
'time.taken.message',
|
|
56
|
+
'time.taken.close',
|
|
57
|
+
];
|
|
58
|
+
/** One chip of the day strip: an `aria-pressed` outline button. */
|
|
59
|
+
export function DefaultDayChip({ label, selected, className, onSelect }) {
|
|
60
|
+
return (_jsx(BookingButton, { variant: "outline", size: "sm", "aria-pressed": selected, onClick: onSelect, className: className, children: label }));
|
|
61
|
+
}
|
|
62
|
+
/** One time chip. */
|
|
63
|
+
export function DefaultTimeChip({ label, nearest, nearestLabel, current, className, onPick, }) {
|
|
64
|
+
if (current) {
|
|
65
|
+
return (_jsx(BookingButton, { variant: "outline", size: "lg", disabled: true, "aria-current": "true", className: className, children: label }));
|
|
66
|
+
}
|
|
67
|
+
return (_jsxs(BookingButton, { variant: "outline", size: "lg", onClick: onPick, className: className, children: [label, nearest && (_jsxs("span", { className: "sr-only", children: [" ", nearestLabel] }))] }));
|
|
68
|
+
}
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// View logic
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
/** How many alternatives to point at when a slot is taken: what fits under the message on a phone. */
|
|
73
|
+
const NEAREST_COUNT = 2;
|
|
74
|
+
function dayStanding(clock, dayTs, openDays, now) {
|
|
75
|
+
if (openDays == null)
|
|
76
|
+
return 'unknown';
|
|
77
|
+
const entry = openDays.find((day) => day.dayKey === clock.dayKey(dayTs));
|
|
78
|
+
if (entry === undefined || entry.lastStartTs === null)
|
|
79
|
+
return 'closed';
|
|
80
|
+
return now > entry.lastStartTs ? 'over' : 'open';
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The strip, in order: the days the caller offered, else the days that have
|
|
84
|
+
* slots, else today — so the empty-day card always has a day to name.
|
|
85
|
+
*
|
|
86
|
+
* Closed days, and today once it is over, are left out (a chip is an offer);
|
|
87
|
+
* a booked-out day keeps its chip, because «full» is something the visitor must
|
|
88
|
+
* be able to read. A day with a bookable slot is always kept whatever the hours
|
|
89
|
+
* say, and so are the day a slot was stolen from and the day of an appointment
|
|
90
|
+
* being moved — the visitor is standing on them.
|
|
91
|
+
*/
|
|
92
|
+
function buildDayStrip(clock, slots, days, takenSlotTs, currentSlotTs, openDays, now) {
|
|
93
|
+
const bookableKeys = new Set(slots.map((slot) => clock.dayKey(slot.startTs)));
|
|
94
|
+
const byKey = new Map();
|
|
95
|
+
for (const day of days ?? slots.map((slot) => slot.startTs)) {
|
|
96
|
+
const standing = dayStanding(clock, day, openDays, now);
|
|
97
|
+
if (!bookableKeys.has(clock.dayKey(day)) && (standing === 'closed' || standing === 'over')) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
byKey.set(clock.dayKey(day), day);
|
|
101
|
+
}
|
|
102
|
+
for (const anchor of [takenSlotTs, currentSlotTs]) {
|
|
103
|
+
if (anchor !== null && !byKey.has(clock.dayKey(anchor))) {
|
|
104
|
+
byKey.set(clock.dayKey(anchor), anchor);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (byKey.size === 0)
|
|
108
|
+
byKey.set(clock.dayKey(now), now);
|
|
109
|
+
return [...byKey.values()].sort((a, b) => a - b);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The one chip a family taps: `15:00 → 16:00`, or the parallel form
|
|
113
|
+
* `15:00 (Name with Stylist · Name with Stylist)`. The parallel chip names the
|
|
114
|
+
* people where the visitor has given a name and the service otherwise.
|
|
115
|
+
*/
|
|
116
|
+
function partyChipLabel(labels, clock, slot, party) {
|
|
117
|
+
if (slot.mode === 'sequential') {
|
|
118
|
+
return fillLabel(labels['time.party.sequential'], {
|
|
119
|
+
start: clock.formatTime(slot.startTs),
|
|
120
|
+
end: clock.formatTime(party.chipEndTs(slot)),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const seats = slot.seats.map((seat, index) => {
|
|
124
|
+
const who = party.items[index]?.bookedForName ?? party.items[index]?.service.name ?? '';
|
|
125
|
+
const stylist = party.resolveStylistName(seat.resourceId);
|
|
126
|
+
// No «with», rather than «with null»: a stylist the catalogue cannot name
|
|
127
|
+
// is a reload of the resource list, not a reason to hide the slot.
|
|
128
|
+
return stylist === null ? who : fillLabel(labels['time.party.seat'], { who, stylist });
|
|
129
|
+
});
|
|
130
|
+
return fillLabel(labels['time.party.parallel'], {
|
|
131
|
+
time: clock.formatTime(slot.startTs),
|
|
132
|
+
seats: seats.join(labels['time.party.seatSeparator']),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The instants on offer — ONE per instant. Availability is per (start,
|
|
137
|
+
* resource), so «first available» returns an 11:00 once per free stylist.
|
|
138
|
+
* First-seen wins, and the stylist it carries is the one submitted. A party
|
|
139
|
+
* slot is reduced to its start and first stylist for everything structural.
|
|
140
|
+
*/
|
|
141
|
+
function offeredSlots(slots, party) {
|
|
142
|
+
if (party === undefined) {
|
|
143
|
+
const byStart = new Map();
|
|
144
|
+
for (const slot of slots) {
|
|
145
|
+
if (!byStart.has(slot.startTs))
|
|
146
|
+
byStart.set(slot.startTs, slot);
|
|
147
|
+
}
|
|
148
|
+
return [...byStart.values()];
|
|
149
|
+
}
|
|
150
|
+
return party.slots.map((slot) => ({
|
|
151
|
+
startTs: slot.startTs,
|
|
152
|
+
resourceId: slot.seats[0]?.resourceId ?? null,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
function weekendSentence(labels, format, note) {
|
|
156
|
+
const price = format.price(note.priceOre);
|
|
157
|
+
return note.pct === null
|
|
158
|
+
? fillLabel(labels['time.weekend.mixed'], { price })
|
|
159
|
+
: fillLabel(labels['time.weekend.pct'], { pct: note.pct, price });
|
|
160
|
+
}
|
|
161
|
+
// Month grid — pure calendar arithmetic over the business-local date, so a
|
|
162
|
+
// viewer in another zone is never shown a grid built around the wrong day.
|
|
163
|
+
function ymd(clock, ts) {
|
|
164
|
+
const [year = 0, month = 1, day = 1] = clock.dayKey(ts).split('-').map(Number);
|
|
165
|
+
return { year, month, day };
|
|
166
|
+
}
|
|
167
|
+
const MONTH_LENGTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
168
|
+
function daysInMonth(year, month) {
|
|
169
|
+
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
170
|
+
return month === 2 && leap ? 29 : (MONTH_LENGTHS[month - 1] ?? 30);
|
|
171
|
+
}
|
|
172
|
+
const SAKAMOTO = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
|
|
173
|
+
/** Monday = 0 … Sunday = 6 for a calendar date. */
|
|
174
|
+
function weekdayIndex(year, month, day) {
|
|
175
|
+
const y = month < 3 ? year - 1 : year;
|
|
176
|
+
const sunday0 = (y +
|
|
177
|
+
Math.floor(y / 4) -
|
|
178
|
+
Math.floor(y / 100) +
|
|
179
|
+
Math.floor(y / 400) +
|
|
180
|
+
SAKAMOTO[month - 1] +
|
|
181
|
+
day) %
|
|
182
|
+
7;
|
|
183
|
+
return (sunday0 + 6) % 7;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Every date of the month `ts` falls in, one instant inside each, padded with
|
|
187
|
+
* `null` so the first lands under its weekday. Stepped with `dayStart`, never
|
|
188
|
+
* `+ 86 400 000`, so a clock change cannot drop or double a day.
|
|
189
|
+
*/
|
|
190
|
+
function monthGrid(clock, ts) {
|
|
191
|
+
const { year, month, day } = ymd(clock, ts);
|
|
192
|
+
const first = clock.dayStart(ts, 1 - day);
|
|
193
|
+
const cells = Array.from({ length: weekdayIndex(year, month, 1) }, () => null);
|
|
194
|
+
for (let index = 0; index < daysInMonth(year, month); index += 1) {
|
|
195
|
+
cells.push(clock.dayStart(first, index));
|
|
196
|
+
}
|
|
197
|
+
return cells;
|
|
198
|
+
}
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// TimeScreen
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
export function TimeScreen({ labels, format, dayparts, slots, days, openDays = null, stylistName = null, takenSlotTs = null, phone = null, weekendNote, currentSlotTs = null, monthView = false, onPick, party, now: nowProp, classNames, components, }) {
|
|
203
|
+
const now = nowProp ?? Date.now();
|
|
204
|
+
const { clock } = format;
|
|
205
|
+
const DayChip = components?.DayChip ?? DefaultDayChip;
|
|
206
|
+
const bookable = offeredSlots(slots, party);
|
|
207
|
+
// The current-hour marker rides along with the real openings so the strip,
|
|
208
|
+
// the daypart grouping and the chip order all place it without a second code
|
|
209
|
+
// path. Every decision about whether there is anything to BOOK reads
|
|
210
|
+
// `bookable` instead.
|
|
211
|
+
const offered = currentSlotTs !== null && !bookable.some((slot) => slot.startTs === currentSlotTs)
|
|
212
|
+
? [...bookable, { startTs: currentSlotTs, resourceId: null }]
|
|
213
|
+
: bookable;
|
|
214
|
+
const partyByStart = new Map(party?.slots.map((slot) => [slot.startTs, slot]) ?? []);
|
|
215
|
+
const strip = buildDayStrip(clock, offered, days, takenSlotTs, currentSlotTs, openDays, now);
|
|
216
|
+
// Read back through the strip rather than held as a day of its own, so new
|
|
217
|
+
// availability cannot leave the step pointing at a day no longer offered.
|
|
218
|
+
const [tappedDayKey, setTappedDayKey] = useState(null);
|
|
219
|
+
const [showMonth, setShowMonth] = useState(false);
|
|
220
|
+
const [monthTs, setMonthTs] = useState(null);
|
|
221
|
+
const selectedDay = strip.find((day) => clock.dayKey(day) === tappedDayKey) ??
|
|
222
|
+
// A stolen slot lands the visitor back on the day they just lost.
|
|
223
|
+
(takenSlotTs === null
|
|
224
|
+
? undefined
|
|
225
|
+
: strip.find((day) => clock.dayKey(day) === clock.dayKey(takenSlotTs))) ??
|
|
226
|
+
// Moving an appointment opens on the day it is on.
|
|
227
|
+
(currentSlotTs === null
|
|
228
|
+
? undefined
|
|
229
|
+
: strip.find((day) => clock.dayKey(day) === clock.dayKey(currentSlotTs))) ??
|
|
230
|
+
strip[0] ??
|
|
231
|
+
now;
|
|
232
|
+
const selectedKey = clock.dayKey(selectedDay);
|
|
233
|
+
const daySlots = offered
|
|
234
|
+
.filter((slot) => clock.dayKey(slot.startTs) === selectedKey)
|
|
235
|
+
.sort((a, b) => a.startTs - b.startTs);
|
|
236
|
+
const dayBookable = daySlots.filter((slot) => slot.startTs !== currentSlotTs);
|
|
237
|
+
const nearest = new Set(takenSlotTs === null
|
|
238
|
+
? []
|
|
239
|
+
: [...dayBookable]
|
|
240
|
+
.sort((a, b) => Math.abs(a.startTs - takenSlotTs) - Math.abs(b.startTs - takenSlotTs))
|
|
241
|
+
.slice(0, NEAREST_COUNT)
|
|
242
|
+
.map((slot) => slot.startTs));
|
|
243
|
+
// Over the openings, never the marker.
|
|
244
|
+
const earliest = bookable.reduce((best, slot) => (best === null || slot.startTs < best.startTs ? slot : best), null);
|
|
245
|
+
const elsewhere = earliest !== null && clock.dayKey(earliest.startTs) !== selectedKey ? earliest : null;
|
|
246
|
+
const isToday = clock.daysBetween(now, selectedDay) === 0;
|
|
247
|
+
// Whether the basket pays a surcharge on ANY day decides whether the row
|
|
248
|
+
// exists; the day on screen only decides what is in it.
|
|
249
|
+
const surcharge = weekendNote?.(selectedDay) ?? null;
|
|
250
|
+
const alternative = party !== undefined && party.mode === 'sequential'
|
|
251
|
+
? (party.alternativeFor?.(selectedDay) ?? null)
|
|
252
|
+
: null;
|
|
253
|
+
return (_jsxs("section", { "aria-labelledby": "booking-time-heading", className: slotClass(classNames, 'root', 'space-y-6'), children: [_jsx(TimeHeading, { labels: labels, className: classNames?.heading }), strip.length > 1 && (_jsxs("fieldset", { className: slotClass(classNames, 'dayStrip', 'flex flex-wrap gap-2'), children: [_jsx("legend", { className: "sr-only", children: labels['time.dayStrip.legend'] }), strip.map((day) => {
|
|
254
|
+
const selected = clock.dayKey(day) === selectedKey;
|
|
255
|
+
return (_jsx(DayChip, { dayTs: day, label: clock.dayChip(day, now), selected: selected, onSelect: () => setTappedDayKey(clock.dayKey(day)), className: cn('rounded-full', selected ? 'border-primary bg-primary/10 font-bold ring-2 ring-primary' : '', classNames?.dayChip, selected ? classNames?.dayChipSelected : undefined) }, clock.dayKey(day)));
|
|
256
|
+
})] })), monthView && (_jsx(MonthCalendar, { labels: labels, clock: clock, open: showMonth, onToggle: () => setShowMonth((shown) => !shown), monthTs: monthTs ?? selectedDay, onMonth: setMonthTs, strip: strip, selectedDay: selectedDay, now: now, onPickDay: (day) => setTappedDayKey(clock.dayKey(day)), classNames: classNames })), surcharge !== null && (_jsx("p", { "data-testid": "surcharge-row", className: slotClass(classNames, 'surcharge', 'min-h-5 text-sm text-muted-foreground'), children: clock.isWeekend(selectedDay) ? weekendSentence(labels, format, surcharge) : null })), dayBookable.length === 0 && alternative && party && (_jsx(ParallelInstead, { labels: labels, clock: clock, slot: alternative, day: selectedDay, now: now, size: party.items.length, onAccept: () => party.onPick(alternative), className: classNames?.parallel })), dayBookable.length === 0 && !(alternative && party) && (_jsx(NothingFreeCard, { labels: labels, format: format, day: selectedDay, now: now, phone: phone, standing: dayStanding(clock, selectedDay, openDays, now), stylistName: stylistName, className: classNames?.empty })), dayBookable.length > 0 && (_jsx(DaypartSections, { labels: labels, clock: clock, dayparts: dayparts, daySlots: daySlots, nearest: nearest, currentSlotTs: currentSlotTs, party: party, partyByStart: partyByStart, onPick: onPick, classNames: classNames, TimeChip: components?.TimeChip ?? DefaultTimeChip })), elsewhere && (_jsx(BookingButton, { variant: "link", onClick: () => setTappedDayKey(clock.dayKey(elsewhere.startTs)), className: classNames?.nextFree, children: labels['time.nextFree'] })), isToday && dayBookable.length > 0 && (_jsx(ShortNoticeLine, { labels: labels, format: format, phone: phone, className: classNames?.shortNotice }))] }));
|
|
257
|
+
}
|
|
258
|
+
function TimeHeading({ labels, className, }) {
|
|
259
|
+
return (_jsx("h2", { id: "booking-time-heading", tabIndex: -1, className: cn('font-sans text-2xl font-bold outline-none md:text-3xl', className), children: labels['time.heading'] }));
|
|
260
|
+
}
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// TakenToast
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
/** How long the «just taken» toast stays up before it gets out of the way. */
|
|
265
|
+
const TOAST_MS = 8000;
|
|
266
|
+
/**
|
|
267
|
+
* «That one was just taken», as a toast over the page. Fixed, so it moves
|
|
268
|
+
* nothing; the ringed chips carry the message once it has gone. Render it ONCE,
|
|
269
|
+
* outside any skeleton-or-step switch, so a re-read of the openings cannot
|
|
270
|
+
* remount it. The live region is mounted empty and filled after mount.
|
|
271
|
+
*/
|
|
272
|
+
export function TakenToast({ labels, takenSlotTs, className }) {
|
|
273
|
+
const [shown, setShown] = useState(false);
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
if (takenSlotTs === null) {
|
|
276
|
+
setShown(false);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
setShown(true);
|
|
280
|
+
const timer = window.setTimeout(() => setShown(false), TOAST_MS);
|
|
281
|
+
return () => window.clearTimeout(timer);
|
|
282
|
+
}, [takenSlotTs]);
|
|
283
|
+
return (_jsx("div", { role: "status", "aria-live": "polite", className: shown
|
|
284
|
+
? cn('fixed inset-x-4 bottom-24 z-50 mx-auto flex max-w-md items-center gap-3 rounded-lg border border-secondary/40 bg-background px-5 py-3 text-sm shadow-lg', className)
|
|
285
|
+
: 'sr-only', children: shown && (_jsxs(_Fragment, { children: [_jsx("p", { className: "flex-1", children: labels['time.taken.message'] }), _jsx("button", { type: "button", "aria-label": labels['time.taken.close'], onClick: () => setShown(false), className: "flex size-8 shrink-0 items-center justify-center rounded-full hover:bg-muted", children: _jsx(X, { "aria-hidden": "true", className: "size-4" }) })] })) }));
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* The time step before its openings are in: the same heading, a strip of day
|
|
289
|
+
* chips and a daypart of time chips, each built from the real chip's own button
|
|
290
|
+
* classes so every box is the same size.
|
|
291
|
+
*/
|
|
292
|
+
export function TimeScreenSkeleton({ labels, days = 7, monthView = false, surchargeRow = false, className, }) {
|
|
293
|
+
const dayChip = bookingButtonClass({
|
|
294
|
+
variant: 'outline',
|
|
295
|
+
size: 'sm',
|
|
296
|
+
className: 'animate-pulse rounded-full border-border bg-muted text-transparent',
|
|
297
|
+
});
|
|
298
|
+
const slotChip = bookingButtonClass({
|
|
299
|
+
variant: 'outline',
|
|
300
|
+
size: 'lg',
|
|
301
|
+
className: 'min-w-20 animate-pulse border-border bg-muted text-transparent',
|
|
302
|
+
});
|
|
303
|
+
return (_jsxs("section", { "aria-labelledby": "booking-time-heading", className: cn('space-y-6', className), children: [_jsx(TimeHeading, { labels: labels }), _jsx(LiveStatus, { text: labels['time.loading'] }), _jsxs("div", { "aria-hidden": "true", className: "space-y-6", children: [_jsx("div", { className: "flex flex-wrap gap-2", children: Array.from({ length: days }, (_, index) => (_jsx("span", { "data-testid": "day-chip-skeleton", className: dayChip, children: labels['time.skeleton.dayPlaceholder'] }, index))) }), monthView && _jsx("span", { className: "block h-8 w-36 animate-pulse rounded bg-muted" }), surchargeRow && _jsx("span", { "data-testid": "surcharge-row-skeleton", className: "block min-h-5" }), _jsxs("div", { className: "space-y-3", children: [_jsx("span", { className: "block h-7 w-32 animate-pulse rounded bg-muted" }), _jsx("div", { className: "flex flex-wrap gap-2", children: Array.from({ length: 8 }, (_, index) => (_jsx("span", { "data-testid": "slot-skeleton", className: slotChip, children: "00:00" }, index))) })] })] })] }));
|
|
304
|
+
}
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// Month view
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
/**
|
|
309
|
+
* «Show the whole month» — the strip's days laid out as the month they sit in.
|
|
310
|
+
* Only those days are tappable; every other cell is inert, because a calendar
|
|
311
|
+
* that let a visitor tap an unasked-about date and then said «full» would be
|
|
312
|
+
* inventing a fact. Collapsed by default; navigable both ways, never disabled.
|
|
313
|
+
*/
|
|
314
|
+
function MonthCalendar({ labels, clock, open, onToggle, monthTs, onMonth, strip, selectedDay, now, onPickDay, classNames, }) {
|
|
315
|
+
const offered = new Map(strip.map((day) => [clock.dayKey(day), day]));
|
|
316
|
+
const cells = open ? monthGrid(clock, monthTs) : [];
|
|
317
|
+
const { year, month, day: anchorDay } = ymd(clock, monthTs);
|
|
318
|
+
const firstOfMonth = clock.dayStart(monthTs, 1 - anchorDay);
|
|
319
|
+
const monthName = clock.monthName(month);
|
|
320
|
+
const heading = fillLabel(labels['time.month.heading'], {
|
|
321
|
+
month: `${monthName.charAt(0).toUpperCase()}${monthName.slice(1)}`,
|
|
322
|
+
year,
|
|
323
|
+
});
|
|
324
|
+
const selectedKey = clock.dayKey(selectedDay);
|
|
325
|
+
return (_jsxs("div", { className: slotClass(classNames, 'month', 'space-y-3'), children: [_jsxs(BookingButton, { variant: "link", size: "sm", "aria-expanded": open, onClick: onToggle, className: "px-0", children: [open ? labels['time.month.hide'] : labels['time.month.show'], open ? (_jsx(ChevronUp, { "aria-hidden": "true", className: "size-4" })) : (_jsx(ChevronDown, { "aria-hidden": "true", className: "size-4" }))] }), open && (_jsxs("div", { className: "space-y-3 rounded-lg border border-border bg-card px-4 py-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx(BookingButton, { variant: "outline", size: "icon", "aria-label": labels['time.month.previous'], onClick: () => onMonth(clock.dayStart(firstOfMonth, -1)), className: "rounded-full", children: _jsx(ChevronLeft, { "aria-hidden": "true", className: "size-4" }) }), _jsx("p", { "aria-live": "polite", className: "font-sans font-bold", children: heading }), _jsx(BookingButton, { variant: "outline", size: "icon", "aria-label": labels['time.month.next'], onClick: () => onMonth(clock.dayStart(firstOfMonth, daysInMonth(year, month))), className: "rounded-full", children: _jsx(ChevronRight, { "aria-hidden": "true", className: "size-4" }) })] }), _jsx("div", { "aria-hidden": "true", className: "grid grid-cols-7 gap-1 text-center", children: clock.weekdayHeads().map((head, index) => (_jsx("span", { className: "text-xs font-bold text-muted-foreground", children: head }, index))) }), _jsx("div", { className: "grid grid-cols-7 gap-1", children: cells.map((cell, index) => {
|
|
326
|
+
if (cell === null) {
|
|
327
|
+
// The blanks before the first of the month; the position is
|
|
328
|
+
// the only thing that tells them apart.
|
|
329
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: padding cells
|
|
330
|
+
return _jsx("span", { "aria-hidden": "true" }, `pad-${index}`);
|
|
331
|
+
}
|
|
332
|
+
const key = clock.dayKey(cell);
|
|
333
|
+
const bookable = offered.get(key);
|
|
334
|
+
const selected = key === selectedKey;
|
|
335
|
+
const { day } = ymd(clock, cell);
|
|
336
|
+
if (bookable === undefined) {
|
|
337
|
+
return (_jsx("span", { className: "flex h-10 items-center justify-center rounded-md text-sm text-muted-foreground/50 tabular-nums", children: day }, key));
|
|
338
|
+
}
|
|
339
|
+
return (_jsx("button", { type: "button", "aria-pressed": selected, "aria-label": clock.dayLabel(bookable, now), onClick: () => onPickDay(bookable), className: cn('flex h-10 items-center justify-center rounded-md border text-sm font-bold tabular-nums transition-colors', selected
|
|
340
|
+
? 'border-primary bg-primary/10 ring-2 ring-primary'
|
|
341
|
+
: 'border-border hover:bg-muted', classNames?.monthCell, selected ? classNames?.monthCellSelected : undefined), children: day }, key));
|
|
342
|
+
}) }), _jsx("p", { className: "text-xs text-muted-foreground", children: labels['time.month.note'] })] }))] }));
|
|
343
|
+
}
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// Dayparts
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
/** The chosen day's openings, grouped under the dayparts. A single-service chip
|
|
348
|
+
* and a whole family's visit are the same chip; only its text and what a tap
|
|
349
|
+
* raises differ. */
|
|
350
|
+
function DaypartSections({ labels, clock, dayparts, daySlots, nearest, currentSlotTs, party, partyByStart, onPick, classNames, TimeChip, }) {
|
|
351
|
+
return dayparts.map((daypart) => {
|
|
352
|
+
const items = daySlots.filter((slot) => clock.daypartOf(slot.startTs) === daypart.key);
|
|
353
|
+
// An empty daypart is not a heading with nothing under it — there is
|
|
354
|
+
// simply no evening on that day.
|
|
355
|
+
if (items.length === 0)
|
|
356
|
+
return null;
|
|
357
|
+
return (_jsxs("section", { "aria-labelledby": `booking-${daypart.key}`, className: slotClass(classNames, 'daypart', 'space-y-3'), children: [_jsx("h3", { id: `booking-${daypart.key}`, className: slotClass(classNames, 'daypartHeading', 'font-sans text-lg font-bold'), children: daypart.label }), _jsx("div", { className: "flex flex-wrap gap-2", children: items.map((slot) => {
|
|
358
|
+
const partySlot = partyByStart.get(slot.startTs);
|
|
359
|
+
const current = slot.startTs === currentSlotTs;
|
|
360
|
+
const isNearest = !current && nearest.has(slot.startTs);
|
|
361
|
+
let label = clock.formatTime(slot.startTs);
|
|
362
|
+
if (current) {
|
|
363
|
+
label = fillLabel(labels['time.current'], { time: label });
|
|
364
|
+
}
|
|
365
|
+
else if (partySlot && party) {
|
|
366
|
+
label = partyChipLabel(labels, clock, partySlot, party);
|
|
367
|
+
}
|
|
368
|
+
return (_jsx(TimeChip, { startTs: slot.startTs, label: label, nearest: isNearest, nearestLabel: labels['time.nearest'], current: current, onPick: () => (partySlot && party ? party.onPick(partySlot) : onPick(slot)), className: cn('min-w-20 tabular-nums', isNearest ? 'border-primary ring-2 ring-primary' : '', classNames?.chip, isNearest ? classNames?.chipNearest : undefined, current ? classNames?.chipCurrent : undefined) }, slot.startTs));
|
|
369
|
+
}) })] }, daypart.key));
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
// Empty-day cards
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
/**
|
|
376
|
+
* «No back-to-back times on Thursday – but both can be seen at once at 15:00»,
|
|
377
|
+
* with the button that takes it up. `onAccept` raises the whole `PartySlot`,
|
|
378
|
+
* `mode` included, so the caller seats it side by side.
|
|
379
|
+
*/
|
|
380
|
+
function ParallelInstead({ labels, clock, slot, day, now, size, onAccept, className, }) {
|
|
381
|
+
const time = clock.formatTime(slot.startTs);
|
|
382
|
+
const offer = size === 2 ? labels['time.parallel.offer.two'] : labels['time.parallel.offer.other'];
|
|
383
|
+
return (_jsxs("div", { className: cn('space-y-3 rounded-lg border border-primary/40 bg-primary/5 px-5 py-4', className), children: [_jsx("p", { className: "text-sm", children: fillLabel(offer, { day: clock.dayLabel(day, now), time }) }), _jsx(BookingButton, { size: "lg", onClick: onAccept, className: "tabular-nums", children: fillLabel(labels['time.parallel.accept'], { time }) })] }));
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* A day with nothing left, in four different situations:
|
|
387
|
+
* - `closed` / `over` — say so, with no telephone (nobody is there, or the
|
|
388
|
+
* last start has gone);
|
|
389
|
+
* - `open` — «full», with the telephone — or, when the visitor asked for one
|
|
390
|
+
* stylist, that stylist has nothing (the hours are the business's, so «full»
|
|
391
|
+
* could be false);
|
|
392
|
+
* - `unknown` — the hours could not be read: «nothing free», true whatever they are.
|
|
393
|
+
*/
|
|
394
|
+
function NothingFreeCard({ labels, format, day, now, phone, standing, stylistName, className, }) {
|
|
395
|
+
const dayLabel = format.clock.dayLabel(day, now);
|
|
396
|
+
const cardClass = cn('rounded-lg border border-border bg-card px-5 py-4 text-sm', className);
|
|
397
|
+
if (standing === 'closed' || standing === 'over') {
|
|
398
|
+
return (_jsx("p", { className: cardClass, children: fillLabel(labels[standing === 'closed' ? 'time.empty.closed' : 'time.empty.over'], {
|
|
399
|
+
day: dayLabel,
|
|
400
|
+
}) }));
|
|
401
|
+
}
|
|
402
|
+
let lead = fillLabel(labels['time.empty.unknown'], { day: dayLabel });
|
|
403
|
+
if (standing === 'open') {
|
|
404
|
+
lead = stylistName
|
|
405
|
+
? fillLabel(labels['time.empty.stylist'], { stylist: stylistName, day: dayLabel })
|
|
406
|
+
: fillLabel(labels['time.empty.full'], { day: dayLabel });
|
|
407
|
+
}
|
|
408
|
+
return (_jsxs("p", { className: cardClass, children: [lead, ' ', phone ? (_jsxs(_Fragment, { children: [_jsx("a", { href: format.telHref(phone), "aria-label": fillLabel(labels['time.empty.callLinkAria'], { phone }), className: "font-semibold text-primary underline underline-offset-4", children: fillLabel(labels['time.empty.callLink'], { phone }) }), labels['time.empty.callSuffix']] })) : (labels['time.empty.callPlain'])] }));
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Today's remaining gaps sit inside the business's lead time, so they are never
|
|
412
|
+
* offered — but the chair may well be empty in an hour, and the visitor on the
|
|
413
|
+
* pavement is the one customer most worth catching.
|
|
414
|
+
*/
|
|
415
|
+
function ShortNoticeLine({ labels, format, phone, className, }) {
|
|
416
|
+
const sentence = labels['time.shortNotice'];
|
|
417
|
+
return (_jsx("p", { className: cn('text-sm text-muted-foreground', className), children: phone ? (_jsx("a", { href: format.telHref(phone), className: "font-semibold text-primary underline underline-offset-4", children: sentence })) : (sentence) }));
|
|
418
|
+
}
|