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