@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.
Files changed (103) hide show
  1. package/README.md +130 -1
  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 +314 -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 +35 -0
  13. package/dist/booking/index.js +40 -0
  14. package/dist/booking/internal/label-parts.d.ts +8 -0
  15. package/dist/booking/internal/label-parts.js +19 -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 +369 -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 +103 -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 +88 -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 +137 -0
  61. package/dist/booking/service-screen.js +233 -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 +117 -0
  65. package/dist/booking/stylist-screen.js +226 -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 +418 -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/avatar.d.ts +24 -0
  77. package/dist/primitives/avatar.js +44 -0
  78. package/dist/primitives/button.d.ts +21 -0
  79. package/dist/primitives/button.js +51 -0
  80. package/dist/primitives/checkbox.d.ts +23 -0
  81. package/dist/primitives/checkbox.js +20 -0
  82. package/dist/primitives/field.d.ts +47 -0
  83. package/dist/primitives/field.js +48 -0
  84. package/dist/primitives/index.d.ts +16 -0
  85. package/dist/primitives/index.js +8 -0
  86. package/dist/primitives/input.d.ts +10 -0
  87. package/dist/primitives/input.js +9 -0
  88. package/dist/primitives/sheet.d.ts +68 -0
  89. package/dist/primitives/sheet.js +303 -0
  90. package/dist/primitives/textarea.d.ts +14 -0
  91. package/dist/primitives/textarea.js +13 -0
  92. package/dist/primitives/toggle-group.d.ts +46 -0
  93. package/dist/primitives/toggle-group.js +148 -0
  94. package/dist/styles/base.css +40 -0
  95. package/dist/styles/booking.css +10 -0
  96. package/dist/styles/bridge.css +159 -0
  97. package/dist/styles/calendar.css +8 -0
  98. package/dist/styles/primitives.css +10 -0
  99. package/dist/styles/theme.css +12 -162
  100. package/dist/styles/vendor/xyflow.css +626 -0
  101. package/dist/styles/workflow-builder.css +17 -0
  102. package/package.json +45 -9
  103. 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
+ }