@tribe-nest/forge 3.64.0 → 3.65.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.
@@ -0,0 +1,874 @@
1
+ import { type CSSProperties, useEffect, useMemo, useState } from "react";
2
+ import { ChevronLeft, ChevronRight, ChevronUp, Check, X } from "lucide-react";
3
+ import type { CoachingProduct } from "../../types/models";
4
+ import type { PickedSlot, useCoachingBooking } from "../headless/coaching/useCoachingBooking";
5
+ import { useForgeT } from "../../i18n";
6
+ import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
7
+ import { bookingSessionRange } from "../format/bookingPricing";
8
+ import { buttonStyle } from "./Button";
9
+ import { Loading } from "./Loading";
10
+ import { CallHelpHint } from "../media/CallHelpHint";
11
+
12
+ type Booking = ReturnType<typeof useCoachingBooking>;
13
+
14
+ /** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
15
+ const a = (hex: string, alpha: number) => hex + Math.round(alpha * 255).toString(16).padStart(2, "0");
16
+
17
+ /**
18
+ * The visitor's LOCAL calendar day of an instant, as `YYYY-MM-DD`.
19
+ *
20
+ * Times are shown in the visitor's timezone, so they must be GROUPED by it too.
21
+ * The previous week list grouped by `toISOString()` (the UTC date) while
22
+ * printing local times, which put a late-evening Lagos slot under the next day.
23
+ */
24
+ export const localDayKey = (d: Date) =>
25
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
26
+
27
+ const parseDayKey = (key: string) => {
28
+ const [y, m, d] = key.split("-").map(Number);
29
+ return new Date(y!, m! - 1, d!);
30
+ };
31
+
32
+ const timeLabel = (iso: string) =>
33
+ new Date(iso).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
34
+ const shortDayLabel = (d: Date) => d.toLocaleDateString(undefined, { weekday: "short", day: "numeric", month: "short" });
35
+ const longDayLabel = (d: Date) => d.toLocaleDateString(undefined, { weekday: "long", day: "numeric", month: "long" });
36
+
37
+ /** True below the phone breakpoint. SSR renders the desktop layout. */
38
+ export function useNarrowViewport(breakpoint = 640) {
39
+ const [narrow, setNarrow] = useState(false);
40
+ useEffect(() => {
41
+ if (typeof window === "undefined" || !window.matchMedia) return;
42
+ const query = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
43
+ const apply = () => setNarrow(query.matches);
44
+ apply();
45
+ query.addEventListener?.("change", apply);
46
+ return () => query.removeEventListener?.("change", apply);
47
+ }, [breakpoint]);
48
+ return narrow;
49
+ }
50
+
51
+ /**
52
+ * Day first, then time: a month calendar where days with open times are
53
+ * filled, and the chosen day's times beside it (below it, on its own screen,
54
+ * on a phone). Multi-session products collect every pick in a basket with the
55
+ * price following the count; the basket is reserved in one call.
56
+ */
57
+ export function CoachingSlotPicker({
58
+ c,
59
+ product,
60
+ fmt,
61
+ narrow,
62
+ }: {
63
+ c: Booking;
64
+ product: CoachingProduct;
65
+ fmt: (n: number) => string;
66
+ narrow: boolean;
67
+ }) {
68
+ const t = useForgeT();
69
+ const theme = useForgeTheme();
70
+ const range = bookingSessionRange(product);
71
+ const multi = range.max > 1;
72
+
73
+ const byDay = useMemo(() => {
74
+ const map = new Map<string, PickedSlot[]>();
75
+ for (const slot of c.availability.data ?? []) {
76
+ const key = localDayKey(new Date(slot.startTime));
77
+ const list = map.get(key) ?? [];
78
+ list.push(slot);
79
+ map.set(key, list);
80
+ }
81
+ for (const list of map.values()) list.sort((x, y) => Date.parse(x.startTime) - Date.parse(y.startTime));
82
+ return map;
83
+ }, [c.availability.data]);
84
+
85
+ const picksPerDay = useMemo(() => {
86
+ const counts = new Map<string, number>();
87
+ for (const s of c.selectedSlots) {
88
+ if (!s.startTime) continue;
89
+ const key = localDayKey(new Date(s.startTime));
90
+ counts.set(key, (counts.get(key) ?? 0) + 1);
91
+ }
92
+ return counts;
93
+ }, [c.selectedSlots]);
94
+
95
+ const [activeDay, setActiveDay] = useState<string | null>(null);
96
+ const [screen, setScreen] = useState<"days" | "times">("days");
97
+ const [sheetOpen, setSheetOpen] = useState(false);
98
+ const [notice, setNotice] = useState<string | null>(null);
99
+
100
+ // On a wide screen the first open day is shown straight away; a phone opens
101
+ // on the month and waits for a tap.
102
+ const firstOpenDay = useMemo(() => [...byDay.keys()].sort()[0] ?? null, [byDay]);
103
+ useEffect(() => {
104
+ if (activeDay && byDay.has(activeDay)) return;
105
+ if (activeDay && parseDayKey(activeDay).getMonth() === c.monthStart.getMonth() && !c.availability.isLoading) {
106
+ // Still this month, just emptied (someone booked it): keep it visible.
107
+ return;
108
+ }
109
+ if (!narrow) setActiveDay(firstOpenDay);
110
+ }, [activeDay, byDay, firstOpenDay, narrow, c.monthStart, c.availability.isLoading]);
111
+
112
+ const pick = (slot: PickedSlot) => {
113
+ setNotice(null);
114
+ const wasPicked = c.selectedSlots.some((s) => s.id === slot.id);
115
+ const ok = c.toggleSlot(slot);
116
+ if (!ok) {
117
+ setNotice(
118
+ range.min === range.max
119
+ ? t("forge.coaching_booking.basket_full_package", { count: range.max })
120
+ : t("forge.coaching_booking.basket_full_limit", { max: range.max }),
121
+ );
122
+ return;
123
+ }
124
+ if (multi && !wasPicked && c.selectedSlots.length + 1 === range.max) {
125
+ setNotice(
126
+ range.min === range.max
127
+ ? t("forge.coaching_booking.basket_full_package", { count: range.max })
128
+ : t("forge.coaching_booking.basket_full_limit", { max: range.max }),
129
+ );
130
+ }
131
+ };
132
+
133
+ const tzAbbr = (() => {
134
+ try {
135
+ return (
136
+ new Intl.DateTimeFormat(undefined, { timeZoneName: "short" })
137
+ .formatToParts(new Date())
138
+ .find((p) => p.type === "timeZoneName")?.value ?? ""
139
+ );
140
+ } catch {
141
+ return "";
142
+ }
143
+ })();
144
+
145
+ const count = c.selectedSlots.length;
146
+ const total = c.quotedTotal;
147
+ const continueLabel = c.isProcessing
148
+ ? t("forge.coaching_booking.reserving")
149
+ : c.canReserve
150
+ ? total > 0
151
+ ? t("forge.coaching_booking.continue_with_total", { amount: fmt(total) })
152
+ : t("forge.coaching_booking.continue")
153
+ : range.min > 1 && count < range.min
154
+ ? t("forge.coaching_booking.pick_more", { count: range.min - count })
155
+ : t("forge.coaching_booking.pick_a_time");
156
+
157
+ const calendar = (
158
+ <MonthCalendar
159
+ c={c}
160
+ theme={theme}
161
+ byDay={byDay}
162
+ picksPerDay={picksPerDay}
163
+ activeDay={activeDay}
164
+ compact={narrow}
165
+ onPickDay={(key) => {
166
+ setNotice(null);
167
+ setActiveDay(key);
168
+ if (narrow) setScreen("times");
169
+ }}
170
+ />
171
+ );
172
+
173
+ const daySlots = activeDay ? (byDay.get(activeDay) ?? []) : [];
174
+ const full = multi && count >= range.max;
175
+
176
+ const times = (
177
+ <div style={{ display: "flex", flexDirection: "column", gap: 12, minWidth: 0 }}>
178
+ {activeDay ? (
179
+ <>
180
+ <h3 data-testid="booking-day-title" style={{ fontSize: 17, fontWeight: 700, margin: 0 }}>
181
+ {longDayLabel(parseDayKey(activeDay))}
182
+ </h3>
183
+ {daySlots.length === 0 ? (
184
+ <p style={emptyStyle(theme)}>{t("forge.coaching_booking.no_times_day")}</p>
185
+ ) : (
186
+ <div
187
+ data-testid="booking-times"
188
+ style={
189
+ narrow
190
+ ? { display: "flex", flexDirection: "column", gap: 8 }
191
+ : { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(104px, 1fr))", gap: 8 }
192
+ }
193
+ >
194
+ {daySlots.map((slot) => {
195
+ const picked = c.selectedSlots.some((s) => s.id === slot.id);
196
+ const disabled = c.isProcessing || (!picked && full);
197
+ return (
198
+ <button
199
+ key={slot.id}
200
+ type="button"
201
+ data-testid="booking-time"
202
+ aria-pressed={picked}
203
+ disabled={disabled}
204
+ onClick={() => pick(slot)}
205
+ style={timeButtonStyle(theme, picked, disabled, narrow)}
206
+ >
207
+ {/* One row, never wrapped: host sites' base CSS makes an svg a
208
+ block, which put the check on its own line above the time. */}
209
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 6, whiteSpace: "nowrap" }}>
210
+ {picked && <Check size={14} style={{ flex: "none", display: "block" }} aria-hidden="true" />}
211
+ {timeLabel(slot.startTime)}
212
+ </span>
213
+ {narrow && (
214
+ <span style={{ fontSize: 12, fontWeight: 500, opacity: 0.75 }}>
215
+ {t("forge.coaching_booking.duration_short", { minutes: product.durationMinutes })}
216
+ </span>
217
+ )}
218
+ </button>
219
+ );
220
+ })}
221
+ </div>
222
+ )}
223
+ </>
224
+ ) : (
225
+ <p style={emptyStyle(theme)}>{t("forge.coaching_booking.pick_day")}</p>
226
+ )}
227
+ {notice && (
228
+ <p role="status" style={noticeStyle(theme)}>
229
+ {notice}
230
+ </p>
231
+ )}
232
+ </div>
233
+ );
234
+
235
+ const basketTitle = !multi
236
+ ? t("forge.coaching_booking.basket_title_single")
237
+ : range.min === range.max
238
+ ? t("forge.coaching_booking.basket_title_package", { count: range.max })
239
+ : t("forge.coaching_booking.basket_title_many");
240
+ const basketCount =
241
+ range.min === range.max
242
+ ? t("forge.coaching_booking.basket_count_package", { picked: count, count: range.max })
243
+ : t("forge.coaching_booking.basket_count_range", { picked: count, max: range.max });
244
+ const priceLine =
245
+ c.pricingMode === "package"
246
+ ? t("forge.coaching_booking.package_one_price", { count: range.max })
247
+ : c.pricingMode === "per_session"
248
+ ? count > 0
249
+ ? t("forge.coaching_booking.per_session_calc", { count, price: fmt(Number(product.price)) })
250
+ : t("forge.coaching_booking.price_per_session", { price: fmt(Number(product.price)) })
251
+ : null;
252
+
253
+ const basketChips = (
254
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }} data-testid="booking-basket">
255
+ {c.selectedSlots.map((s) => (
256
+ <span key={s.id} style={chipStyle(theme)} data-testid="booking-basket-item">
257
+ {s.startTime ? `${shortDayLabel(new Date(s.startTime))}, ${timeLabel(s.startTime)}` : ""}
258
+ {multi && (
259
+ <button
260
+ type="button"
261
+ aria-label={t("forge.coaching_booking.remove_time", {
262
+ time: s.startTime ? `${shortDayLabel(new Date(s.startTime))} ${timeLabel(s.startTime)}` : "",
263
+ })}
264
+ onClick={() => c.removeSlot(s.id)}
265
+ style={chipRemoveStyle(theme)}
266
+ >
267
+ <X size={12} />
268
+ </button>
269
+ )}
270
+ </span>
271
+ ))}
272
+ {count === 0 && <span style={ghostChipStyle(theme)}>{t("forge.coaching_booking.basket_empty")}</span>}
273
+ {range.min === range.max && multi && count > 0 && count < range.max && (
274
+ <span style={ghostChipStyle(theme)}>{t("forge.coaching_booking.basket_more", { count: range.max - count })}</span>
275
+ )}
276
+ </div>
277
+ );
278
+
279
+ const meter = multi ? (
280
+ <span aria-hidden="true" style={{ display: "inline-flex", gap: 3 }}>
281
+ {Array.from({ length: Math.min(range.max, 12) }, (_, i) => (
282
+ <i
283
+ key={i}
284
+ style={{
285
+ width: 14,
286
+ height: 5,
287
+ borderRadius: 3,
288
+ background: i < count ? theme.colors.primary : a(theme.colors.text, 0.15),
289
+ }}
290
+ />
291
+ ))}
292
+ </span>
293
+ ) : null;
294
+
295
+ const packageHelp =
296
+ c.pricingMode === "package" ? (
297
+ <CallHelpHint label={t("forge.coaching_booking.package_help_label")}>
298
+ {t("forge.coaching_booking.package_help_body")}
299
+ </CallHelpHint>
300
+ ) : null;
301
+
302
+ const continueButton = (
303
+ <button
304
+ type="button"
305
+ data-testid="booking-continue"
306
+ onClick={c.reserveSelected}
307
+ disabled={!c.canReserve || c.isProcessing}
308
+ style={{ ...buttonStyle(theme, { variant: "primary" }), opacity: !c.canReserve || c.isProcessing ? 0.55 : 1, whiteSpace: "nowrap" }}
309
+ >
310
+ {continueLabel}
311
+ </button>
312
+ );
313
+
314
+ const errorLine = c.error ? (
315
+ <p role="alert" style={{ color: "#ef4444", fontSize: 14, margin: 0 }}>
316
+ {c.error}
317
+ </p>
318
+ ) : null;
319
+
320
+ const tzLine = (
321
+ <p style={{ fontSize: 12.5, color: a(theme.colors.text, 0.6), margin: 0 }}>
322
+ {tzAbbr
323
+ ? t("forge.coaching_booking.timezone_note_named", { timezone: tzAbbr })
324
+ : t("forge.coaching_booking.timezone_note")}
325
+ </p>
326
+ );
327
+
328
+ // ── Phone: one thing per screen, basket pinned to the thumb zone ─────────────
329
+ if (narrow) {
330
+ return (
331
+ <div style={{ display: "flex", flexDirection: "column", minHeight: "100%", position: "relative" }}>
332
+ <div style={{ flex: 1, padding: "4px 0 16px", display: "flex", flexDirection: "column", gap: 12 }}>
333
+ {screen === "days" ? (
334
+ <>
335
+ {calendar}
336
+ {firstOpenDay && (
337
+ <button
338
+ type="button"
339
+ data-testid="booking-next-available"
340
+ onClick={() => {
341
+ setActiveDay(firstOpenDay);
342
+ setScreen("times");
343
+ }}
344
+ style={nextAvailableStyle(theme)}
345
+ >
346
+ <span style={{ textAlign: "left" }}>
347
+ <span style={{ display: "block", fontSize: 12, opacity: 0.65, fontWeight: 600 }}>
348
+ {t("forge.coaching_booking.next_available")}
349
+ </span>
350
+ {shortDayLabel(parseDayKey(firstOpenDay))}
351
+ </span>
352
+ <ChevronRight size={18} />
353
+ </button>
354
+ )}
355
+ {tzLine}
356
+ </>
357
+ ) : (
358
+ <>
359
+ <button
360
+ type="button"
361
+ data-testid="booking-back-to-month"
362
+ onClick={() => setScreen("days")}
363
+ style={{ ...linkButtonStyle(theme), alignSelf: "flex-start", display: "inline-flex", alignItems: "center", gap: 4 }}
364
+ >
365
+ <ChevronLeft size={16} style={{ flex: "none", display: "block" }} aria-hidden="true" />
366
+ {c.monthStart.toLocaleDateString(undefined, { month: "long" })}
367
+ </button>
368
+ <DayStrip
369
+ theme={theme}
370
+ days={[...byDay.keys()].sort()}
371
+ activeDay={activeDay}
372
+ picksPerDay={picksPerDay}
373
+ onPick={(key) => {
374
+ setNotice(null);
375
+ setActiveDay(key);
376
+ }}
377
+ />
378
+ {times}
379
+ </>
380
+ )}
381
+ {errorLine}
382
+ </div>
383
+ <div style={mobileBarStyle(theme)}>
384
+ <button
385
+ type="button"
386
+ data-testid="booking-basket-toggle"
387
+ onClick={() => multi && setSheetOpen(true)}
388
+ style={{ flex: 1, minWidth: 0, textAlign: "left", background: "transparent", border: 0, padding: 0, color: theme.colors.text, cursor: multi ? "pointer" : "default", fontFamily: theme.fontFamily }}
389
+ >
390
+ {multi ? (
391
+ <>
392
+ <b style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 14 }}>
393
+ {basketCount}
394
+ {count > 0 && <ChevronUp size={14} style={{ flex: "none", display: "block" }} aria-hidden="true" />}
395
+ </b>
396
+ <span style={{ fontSize: 12, opacity: 0.65 }}>{priceLine}</span>
397
+ </>
398
+ ) : c.selectedSlots[0]?.startTime ? (
399
+ <>
400
+ <b style={{ display: "block", fontSize: 14 }}>{shortDayLabel(new Date(c.selectedSlots[0].startTime))}</b>
401
+ <span style={{ fontSize: 12, opacity: 0.65 }}>{timeLabel(c.selectedSlots[0].startTime)}</span>
402
+ </>
403
+ ) : (
404
+ <b style={{ fontSize: 14, opacity: 0.7 }}>{t("forge.coaching_booking.basket_empty")}</b>
405
+ )}
406
+ </button>
407
+ {continueButton}
408
+ </div>
409
+ {sheetOpen && multi && (
410
+ <>
411
+ <div onClick={() => setSheetOpen(false)} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.42)", zIndex: 60 }} />
412
+ <div role="dialog" aria-label={basketTitle} data-testid="booking-basket-sheet" style={sheetStyle(theme)}>
413
+ <div style={{ width: 40, height: 5, borderRadius: 3, background: a(theme.colors.text, 0.2), margin: "0 auto 12px" }} />
414
+ <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
415
+ <h3 style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>{basketTitle}</h3>
416
+ {packageHelp}
417
+ </div>
418
+ <p style={{ fontSize: 13, opacity: 0.65, margin: "4px 0 12px" }}>
419
+ {c.pricingMode === "package"
420
+ ? t("forge.coaching_booking.package_total", { price: fmt(total), count: range.max })
421
+ : priceLine}
422
+ </p>
423
+ <div style={{ overflowY: "auto", display: "flex", flexDirection: "column" }}>
424
+ {c.selectedSlots.map((s) => (
425
+ <div key={s.id} style={sheetRowStyle(theme)}>
426
+ <span>{s.startTime ? `${shortDayLabel(new Date(s.startTime))}, ${timeLabel(s.startTime)}` : ""}</span>
427
+ <button type="button" onClick={() => c.removeSlot(s.id)} style={sheetRemoveStyle(theme)}>
428
+ {t("forge.coaching_booking.remove")}
429
+ </button>
430
+ </div>
431
+ ))}
432
+ {range.min === range.max &&
433
+ Array.from({ length: Math.max(0, range.max - count) }, (_, i) => (
434
+ <div key={`empty-${i}`} style={{ ...sheetRowStyle(theme), opacity: 0.45, fontStyle: "italic" }}>
435
+ {t("forge.coaching_booking.not_picked_yet")}
436
+ </div>
437
+ ))}
438
+ </div>
439
+ <button
440
+ type="button"
441
+ onClick={() => setSheetOpen(false)}
442
+ style={{ ...buttonStyle(theme, { variant: "primary", fullWidth: true }), marginTop: 14 }}
443
+ >
444
+ {t("forge.coaching_booking.done")}
445
+ </button>
446
+ </div>
447
+ </>
448
+ )}
449
+ </div>
450
+ );
451
+ }
452
+
453
+ // ── Wide: calendar beside the day's times, basket and total underneath ───────
454
+ return (
455
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
456
+ <div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.1fr) minmax(0, 1fr)", gap: 24, alignItems: "start" }}>
457
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
458
+ {calendar}
459
+ {tzLine}
460
+ </div>
461
+ {c.availability.isLoading ? <Loading label={t("forge.coaching_booking.loading_slots")} /> : times}
462
+ </div>
463
+ <div
464
+ style={{
465
+ borderTop: `1px solid ${a(theme.colors.text, 0.12)}`,
466
+ paddingTop: 16,
467
+ display: "flex",
468
+ flexWrap: "wrap",
469
+ alignItems: "flex-end",
470
+ justifyContent: "space-between",
471
+ gap: 16,
472
+ }}
473
+ >
474
+ <div style={{ flex: "1 1 260px", minWidth: 0, display: "flex", flexDirection: "column", gap: 8 }}>
475
+ <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
476
+ <b style={{ fontSize: 15 }}>{basketTitle}</b>
477
+ {multi && <span style={{ fontSize: 13, opacity: 0.65 }}>{basketCount}</span>}
478
+ {meter}
479
+ </div>
480
+ {basketChips}
481
+ </div>
482
+ <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 8 }}>
483
+ {priceLine && (
484
+ <span style={{ fontSize: 13, opacity: 0.7, display: "inline-flex", alignItems: "center", gap: 4 }}>
485
+ {priceLine}
486
+ {packageHelp}
487
+ </span>
488
+ )}
489
+ {continueButton}
490
+ </div>
491
+ </div>
492
+ {errorLine}
493
+ </div>
494
+ );
495
+ }
496
+
497
+ // ── Month grid ────────────────────────────────────────────────────────────────
498
+ function MonthCalendar({
499
+ c,
500
+ theme,
501
+ byDay,
502
+ picksPerDay,
503
+ activeDay,
504
+ compact,
505
+ onPickDay,
506
+ }: {
507
+ c: Booking;
508
+ theme: ForgeTheme;
509
+ byDay: Map<string, PickedSlot[]>;
510
+ picksPerDay: Map<string, number>;
511
+ activeDay: string | null;
512
+ compact: boolean;
513
+ onPickDay: (key: string) => void;
514
+ }) {
515
+ const t = useForgeT();
516
+ const month = c.monthStart;
517
+ const daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
518
+ const lead = new Date(month.getFullYear(), month.getMonth(), 1).getDay();
519
+ const todayKey = localDayKey(new Date());
520
+ // Weekday initials in the visitor's locale, Sunday first (matches `lead`).
521
+ const weekdays = Array.from({ length: 7 }, (_, i) =>
522
+ new Date(2026, 1, 1 + i).toLocaleDateString(undefined, { weekday: compact ? "narrow" : "short" }),
523
+ );
524
+
525
+ const nav = (disabled: boolean): CSSProperties => ({
526
+ width: 36,
527
+ height: 36,
528
+ borderRadius: 999,
529
+ border: `1px solid ${a(theme.colors.text, 0.18)}`,
530
+ background: "transparent",
531
+ color: theme.colors.text,
532
+ display: "grid",
533
+ placeItems: "center",
534
+ cursor: disabled ? "not-allowed" : "pointer",
535
+ opacity: disabled ? 0.35 : 1,
536
+ });
537
+
538
+ return (
539
+ <div data-testid="booking-calendar">
540
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 10 }}>
541
+ <h3 data-testid="booking-month-label" style={{ fontSize: 17, fontWeight: 700, margin: 0 }}>
542
+ {month.toLocaleDateString(undefined, { month: "long", year: "numeric" })}
543
+ </h3>
544
+ <div style={{ display: "flex", gap: 6 }}>
545
+ <button
546
+ type="button"
547
+ aria-label={t("forge.coaching_booking.aria_prev_month")}
548
+ data-testid="booking-prev-month"
549
+ disabled={!c.canGoPrevMonth}
550
+ onClick={c.goPrevMonth}
551
+ style={nav(!c.canGoPrevMonth)}
552
+ >
553
+ <ChevronLeft size={16} />
554
+ </button>
555
+ <button
556
+ type="button"
557
+ aria-label={t("forge.coaching_booking.aria_next_month")}
558
+ data-testid="booking-next-month"
559
+ disabled={!c.canGoNextMonth}
560
+ onClick={c.goNextMonth}
561
+ style={nav(!c.canGoNextMonth)}
562
+ >
563
+ <ChevronRight size={16} />
564
+ </button>
565
+ </div>
566
+ </div>
567
+ <div role="grid" style={{ display: "grid", gridTemplateColumns: "repeat(7, minmax(0, 1fr))", gap: 4, textAlign: "center" }}>
568
+ {weekdays.map((w, i) => (
569
+ <div key={i} style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.05em", opacity: 0.5, padding: "2px 0 6px", textTransform: "uppercase" }}>
570
+ {w}
571
+ </div>
572
+ ))}
573
+ {Array.from({ length: lead }, (_, i) => (
574
+ <div key={`lead-${i}`} />
575
+ ))}
576
+ {Array.from({ length: daysInMonth }, (_, i) => {
577
+ const date = new Date(month.getFullYear(), month.getMonth(), i + 1);
578
+ const key = localDayKey(date);
579
+ const open = (byDay.get(key)?.length ?? 0) > 0;
580
+ const active = key === activeDay;
581
+ const picks = picksPerDay.get(key) ?? 0;
582
+ const label = t(open ? "forge.coaching_booking.day_has_times" : "forge.coaching_booking.day_no_times", {
583
+ date: longDayLabel(date),
584
+ });
585
+ return (
586
+ <button
587
+ key={key}
588
+ type="button"
589
+ data-testid="booking-day"
590
+ data-day={key}
591
+ data-open={open ? "true" : "false"}
592
+ aria-label={label}
593
+ aria-pressed={active}
594
+ disabled={!open}
595
+ onClick={() => onPickDay(key)}
596
+ style={{
597
+ position: "relative",
598
+ width: "100%",
599
+ maxWidth: 44,
600
+ aspectRatio: "1",
601
+ margin: "0 auto",
602
+ borderRadius: 999,
603
+ border: 0,
604
+ fontFamily: theme.fontFamily,
605
+ fontSize: 14,
606
+ fontVariantNumeric: "tabular-nums",
607
+ fontWeight: open ? 700 : 400,
608
+ cursor: open ? "pointer" : "default",
609
+ background: active ? theme.colors.primary : open ? a(theme.colors.primary, 0.12) : "transparent",
610
+ color: active ? "#fff" : open ? theme.colors.primary : a(theme.colors.text, 0.35),
611
+ textDecoration: key === todayKey ? "underline" : "none",
612
+ textUnderlineOffset: 3,
613
+ }}
614
+ >
615
+ {i + 1}
616
+ {picks > 0 && (
617
+ <span
618
+ data-testid="booking-day-picks"
619
+ style={{
620
+ position: "absolute",
621
+ top: -3,
622
+ right: -3,
623
+ minWidth: 17,
624
+ height: 17,
625
+ padding: "0 4px",
626
+ borderRadius: 999,
627
+ background: theme.colors.text,
628
+ color: theme.colors.background,
629
+ fontSize: 10.5,
630
+ fontWeight: 700,
631
+ display: "grid",
632
+ placeItems: "center",
633
+ textDecoration: "none",
634
+ }}
635
+ >
636
+ {picks}
637
+ </span>
638
+ )}
639
+ </button>
640
+ );
641
+ })}
642
+ </div>
643
+ {!c.availability.isLoading && byDay.size === 0 && (
644
+ <p style={{ ...emptyStyle(theme), marginTop: 12 }}>{t("forge.coaching_booking.no_times_month")}</p>
645
+ )}
646
+ </div>
647
+ );
648
+ }
649
+
650
+ // ── Phone: swipeable strip of this month's open days ─────────────────────────
651
+ function DayStrip({
652
+ theme,
653
+ days,
654
+ activeDay,
655
+ picksPerDay,
656
+ onPick,
657
+ }: {
658
+ theme: ForgeTheme;
659
+ days: string[];
660
+ activeDay: string | null;
661
+ picksPerDay: Map<string, number>;
662
+ onPick: (key: string) => void;
663
+ }) {
664
+ return (
665
+ <div
666
+ data-testid="booking-day-strip"
667
+ style={{ display: "flex", gap: 6, overflowX: "auto", margin: "0 -16px", padding: "7px 16px 6px", scrollbarWidth: "none" }}
668
+ >
669
+ {days.map((key) => {
670
+ const date = parseDayKey(key);
671
+ const active = key === activeDay;
672
+ const picks = picksPerDay.get(key) ?? 0;
673
+ return (
674
+ <button
675
+ key={key}
676
+ type="button"
677
+ aria-pressed={active}
678
+ onClick={() => onPick(key)}
679
+ style={{
680
+ position: "relative",
681
+ flex: "none",
682
+ width: 50,
683
+ padding: "7px 0",
684
+ borderRadius: 12,
685
+ border: `1.5px solid ${active ? theme.colors.primary : a(theme.colors.text, 0.15)}`,
686
+ background: active ? theme.colors.primary : "transparent",
687
+ color: active ? "#fff" : theme.colors.text,
688
+ display: "flex",
689
+ flexDirection: "column",
690
+ alignItems: "center",
691
+ fontFamily: theme.fontFamily,
692
+ cursor: "pointer",
693
+ }}
694
+ >
695
+ <small style={{ fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", opacity: active ? 0.9 : 0.6 }}>
696
+ {date.toLocaleDateString(undefined, { weekday: "short" })}
697
+ </small>
698
+ <b style={{ fontSize: 17 }}>{date.getDate()}</b>
699
+ {picks > 0 && (
700
+ <span
701
+ style={{
702
+ position: "absolute",
703
+ top: -6,
704
+ right: -4,
705
+ minWidth: 17,
706
+ height: 17,
707
+ borderRadius: 999,
708
+ background: theme.colors.text,
709
+ color: theme.colors.background,
710
+ fontSize: 10.5,
711
+ fontWeight: 700,
712
+ display: "grid",
713
+ placeItems: "center",
714
+ padding: "0 4px",
715
+ }}
716
+ >
717
+ {picks}
718
+ </span>
719
+ )}
720
+ </button>
721
+ );
722
+ })}
723
+ </div>
724
+ );
725
+ }
726
+
727
+ // ── Styles ────────────────────────────────────────────────────────────────────
728
+ const timeButtonStyle = (theme: ForgeTheme, picked: boolean, disabled: boolean, narrow: boolean): CSSProperties => ({
729
+ display: "flex",
730
+ alignItems: "center",
731
+ justifyContent: narrow ? "space-between" : "center",
732
+ minHeight: narrow ? 52 : 44,
733
+ padding: narrow ? "0 16px" : "10px 8px",
734
+ borderRadius: theme.cornerRadius,
735
+ border: `1.5px ${disabled && !picked ? "dashed" : "solid"} ${picked ? theme.colors.primary : a(theme.colors.text, 0.18)}`,
736
+ background: picked ? theme.colors.primary : "transparent",
737
+ color: picked ? "#fff" : theme.colors.text,
738
+ fontFamily: theme.fontFamily,
739
+ fontWeight: 700,
740
+ fontSize: narrow ? 15.5 : 14,
741
+ fontVariantNumeric: "tabular-nums",
742
+ cursor: disabled ? "not-allowed" : "pointer",
743
+ opacity: disabled && !picked ? 0.4 : 1,
744
+ });
745
+
746
+ const emptyStyle = (theme: ForgeTheme): CSSProperties => ({
747
+ margin: 0,
748
+ padding: 14,
749
+ borderRadius: theme.cornerRadius,
750
+ background: a(theme.colors.text, 0.05),
751
+ fontSize: 14,
752
+ opacity: 0.8,
753
+ });
754
+
755
+ const noticeStyle = (theme: ForgeTheme): CSSProperties => ({
756
+ margin: 0,
757
+ padding: "9px 12px",
758
+ borderRadius: theme.cornerRadius,
759
+ background: a(theme.colors.primary, 0.1),
760
+ color: theme.colors.text,
761
+ fontSize: 13,
762
+ fontWeight: 600,
763
+ });
764
+
765
+ const chipStyle = (theme: ForgeTheme): CSSProperties => ({
766
+ display: "inline-flex",
767
+ alignItems: "center",
768
+ gap: 8,
769
+ padding: "5px 6px 5px 12px",
770
+ borderRadius: 999,
771
+ background: a(theme.colors.text, 0.07),
772
+ fontSize: 13,
773
+ fontWeight: 600,
774
+ fontVariantNumeric: "tabular-nums",
775
+ });
776
+
777
+ const chipRemoveStyle = (theme: ForgeTheme): CSSProperties => ({
778
+ width: 22,
779
+ height: 22,
780
+ borderRadius: 999,
781
+ border: 0,
782
+ display: "grid",
783
+ placeItems: "center",
784
+ background: theme.colors.background,
785
+ color: theme.colors.text,
786
+ cursor: "pointer",
787
+ });
788
+
789
+ const ghostChipStyle = (theme: ForgeTheme): CSSProperties => ({
790
+ display: "inline-flex",
791
+ alignItems: "center",
792
+ padding: "5px 12px",
793
+ borderRadius: 999,
794
+ border: `1.5px dashed ${a(theme.colors.text, 0.2)}`,
795
+ fontSize: 13,
796
+ opacity: 0.6,
797
+ });
798
+
799
+ const linkButtonStyle = (theme: ForgeTheme): CSSProperties => ({
800
+ border: 0,
801
+ background: "transparent",
802
+ padding: 0,
803
+ color: theme.colors.primary,
804
+ fontWeight: 700,
805
+ fontSize: 14,
806
+ cursor: "pointer",
807
+ fontFamily: theme.fontFamily,
808
+ });
809
+
810
+ const nextAvailableStyle = (theme: ForgeTheme): CSSProperties => ({
811
+ width: "100%",
812
+ display: "flex",
813
+ alignItems: "center",
814
+ justifyContent: "space-between",
815
+ padding: "12px 14px",
816
+ borderRadius: theme.cornerRadius,
817
+ border: `1.5px solid ${a(theme.colors.text, 0.15)}`,
818
+ background: "transparent",
819
+ color: theme.colors.text,
820
+ fontWeight: 700,
821
+ fontFamily: theme.fontFamily,
822
+ cursor: "pointer",
823
+ });
824
+
825
+ const mobileBarStyle = (theme: ForgeTheme): CSSProperties => ({
826
+ position: "sticky",
827
+ bottom: 0,
828
+ margin: "0 -16px",
829
+ display: "flex",
830
+ alignItems: "center",
831
+ gap: 10,
832
+ padding: "10px 16px calc(14px + env(safe-area-inset-bottom, 0px))",
833
+ borderTop: `1px solid ${a(theme.colors.text, 0.12)}`,
834
+ background: theme.colors.background,
835
+ zIndex: 2,
836
+ });
837
+
838
+ const sheetStyle = (theme: ForgeTheme): CSSProperties => ({
839
+ position: "fixed",
840
+ left: 0,
841
+ right: 0,
842
+ bottom: 0,
843
+ zIndex: 61,
844
+ maxHeight: "78vh",
845
+ display: "flex",
846
+ flexDirection: "column",
847
+ padding: "8px 16px calc(20px + env(safe-area-inset-bottom, 0px))",
848
+ borderRadius: "20px 20px 0 0",
849
+ background: theme.colors.background,
850
+ color: theme.colors.text,
851
+ fontFamily: theme.fontFamily,
852
+ });
853
+
854
+ const sheetRowStyle = (theme: ForgeTheme): CSSProperties => ({
855
+ display: "flex",
856
+ justifyContent: "space-between",
857
+ alignItems: "center",
858
+ padding: "12px 0",
859
+ borderBottom: `1px solid ${a(theme.colors.text, 0.1)}`,
860
+ fontWeight: 600,
861
+ fontVariantNumeric: "tabular-nums",
862
+ });
863
+
864
+ const sheetRemoveStyle = (theme: ForgeTheme): CSSProperties => ({
865
+ border: 0,
866
+ borderRadius: 999,
867
+ padding: "6px 12px",
868
+ background: a(theme.colors.text, 0.07),
869
+ color: theme.colors.text,
870
+ fontSize: 12.5,
871
+ fontWeight: 700,
872
+ cursor: "pointer",
873
+ fontFamily: theme.fontFamily,
874
+ });