@tribe-nest/forge 3.64.0 → 3.67.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 (40) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/useCoachingAvailability.ts +18 -6
  3. package/src/data/queries/useMyBookings.ts +7 -0
  4. package/src/data/queries/useWebsite.ts +42 -11
  5. package/src/data/queries/useWebsiteAgent.ts +136 -6
  6. package/src/i18n/de.json +63 -0
  7. package/src/i18n/en.json +63 -0
  8. package/src/provider/ForgeProvider.tsx +7 -1
  9. package/src/types/models.ts +13 -0
  10. package/src/ui/format/_tests/ticketPrice.spec.ts +58 -0
  11. package/src/ui/format/bookingPricing.ts +55 -0
  12. package/src/ui/format/ticketPrice.ts +52 -0
  13. package/src/ui/headless/agent/_tests/callChallenge.spec.ts +85 -0
  14. package/src/ui/headless/agent/_tests/useAgentCall.spec.tsx +213 -0
  15. package/src/ui/headless/agent/callChallenge.ts +72 -0
  16. package/src/ui/headless/agent/useAgentCall.ts +189 -0
  17. package/src/ui/headless/agent/useAgentCallRoom.ts +113 -0
  18. package/src/ui/headless/agent/useAiAgent.ts +8 -20
  19. package/src/ui/headless/agent/visitorId.ts +20 -0
  20. package/src/ui/headless/coaching/useCoachingBooking.ts +139 -13
  21. package/src/ui/index.ts +24 -0
  22. package/src/ui/styled/AccountDashboard.tsx +11 -3
  23. package/src/ui/styled/AiAgentCallRoom.tsx +76 -0
  24. package/src/ui/styled/AiAgentWidget.tsx +276 -21
  25. package/src/ui/styled/BookingPriceSummary.tsx +57 -0
  26. package/src/ui/styled/CoachingBooking.tsx +36 -158
  27. package/src/ui/styled/CoachingConfirmation.tsx +13 -2
  28. package/src/ui/styled/CoachingDetail.tsx +8 -2
  29. package/src/ui/styled/CoachingSlotPicker.tsx +874 -0
  30. package/src/ui/styled/CookieConsent.tsx +13 -9
  31. package/src/ui/styled/EventDetail.tsx +29 -6
  32. package/src/ui/styled/EventTickets.tsx +35 -11
  33. package/src/ui/styled/MusicLinkPage.tsx +6 -3
  34. package/src/ui/styled/_tests/CoachingMultiSession.spec.tsx +299 -0
  35. package/src/ui/styled/_tests/FreeTickets.spec.tsx +242 -0
  36. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +18 -4
  37. package/src/ui/styled/forge-utilities.css +0 -5
  38. package/src/ui/styled/seating/EventSeatPicker.tsx +2 -0
  39. package/src/ui/styled/seating/SeatLegend.tsx +3 -1
  40. package/src/utils/structuredData.ts +4 -1
@@ -4,6 +4,7 @@ import { useForgeT } from "../../i18n";
4
4
  import { useCookieConsent } from "../headless/consent/useCookieConsent";
5
5
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
6
6
  import { readableTextOn } from "../theme/contrast";
7
+ import { useBrandLegalUrls } from "../../data/queries/useWebsite";
7
8
 
8
9
  export interface CookieConsentProps {
9
10
  /** Heading text. Defaults to Forge's own translated heading. */
@@ -17,19 +18,20 @@ export interface CookieConsentProps {
17
18
  /** Link to the site's own privacy policy (rendered after the description). */
18
19
  privacyPolicyHref?: string;
19
20
  /**
20
- * Fall back to TribeNest's hosted privacy policy
21
- * (https://www.tribenest.co/privacy) when the site doesn't have its own.
21
+ * Fall back to the brand's hosted privacy policy when the site doesn't have
22
+ * its own: TribeNest's, or Mandatera's for a law firm, from `site-config`
23
+ * (https://www.tribenest.co/privacy when the backend sends none).
22
24
  * Ignored if `privacyPolicyHref` is set. Opens in a new tab.
23
25
  */
24
26
  useTribeNestPrivacy?: boolean;
25
27
  /**
26
28
  * Controlled visibility. Leave undefined for the default behaviour (show once
27
29
  * until the visitor decides). Set `true` to force it open again so a visitor
28
- * can change their choice — wire a "Cookie settings" link to a boolean state
30
+ * can change their choice: wire a "Cookie settings" link to a boolean state
29
31
  * and pass it here (see the site-starter footer for the pattern).
30
32
  */
31
33
  open?: boolean;
32
- /** Called after the visitor makes a choice or dismisses — use it to reset your
34
+ /** Called after the visitor makes a choice or dismisses. Use it to reset your
33
35
  * controlled `open` state back to false. */
34
36
  onClose?: () => void;
35
37
  /** Extra class on the outer wrapper. */
@@ -42,8 +44,8 @@ const TRIBENEST_PRIVACY_URL = "https://www.tribenest.co/privacy";
42
44
 
43
45
  /**
44
46
  * Drop-in cookie-consent banner. Shows once until the visitor decides, writes to
45
- * the shared consent store, and — because `<ForgeAnalytics />` reads the same
46
- * store — actually gates first-party analytics (performance) and the Meta Pixel
47
+ * the shared consent store, and (because `<ForgeAnalytics />` reads the same
48
+ * store) actually gates first-party analytics (performance) and the Meta Pixel
47
49
  * (marketing). Themed via Forge tokens. Drop it into the site root:
48
50
  * `<CookieConsent privacyPolicyHref="/privacy" />`.
49
51
  */
@@ -59,8 +61,10 @@ export function CookieConsent({
59
61
  }: CookieConsentProps) {
60
62
  const t = useThemeTokens();
61
63
  const tr = useForgeT();
62
- const privacyHref = privacyPolicyHref ?? (useTribeNestPrivacy ? TRIBENEST_PRIVACY_URL : undefined);
63
- // External TribeNest link opens in a new tab; a same-origin custom page stays.
64
+ const brandLegal = useBrandLegalUrls();
65
+ const privacyHref =
66
+ privacyPolicyHref ?? (useTribeNestPrivacy ? (brandLegal?.privacy ?? TRIBENEST_PRIVACY_URL) : undefined);
67
+ // The brand's hosted policy opens in a new tab; a same-origin custom page stays.
64
68
  const privacyExternal = !privacyPolicyHref && useTribeNestPrivacy;
65
69
  const { ready, decided, consent, acceptAll, rejectAll, save } = useCookieConsent();
66
70
  const [draft, setDraft] = useState<Draft>({
@@ -121,7 +125,7 @@ export function CookieConsent({
121
125
  <h2 style={{ margin: 0, fontSize: 18, fontWeight: 800, fontFamily: t.headingFontFamily }}>
122
126
  {title ?? tr("forge.cookie_consent.title")}
123
127
  </h2>
124
- {/* Close is only meaningful once a choice exists (reopened panel) —
128
+ {/* Close is only meaningful once a choice exists (reopened panel);
125
129
  on first run the visitor must pick an option. */}
126
130
  {decided && (
127
131
  <button
@@ -4,7 +4,8 @@ import { usePresaleCode } from "../headless/event/usePresaleCode";
4
4
  import { useForgeT } from "../../i18n";
5
5
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
6
6
  import { useAmountFormatter } from "../format/useFormatCurrency";
7
- import { PriceDisplay } from "../format/PriceDisplay";
7
+ import { PriceDisplay, priceTaxCaption } from "../format/PriceDisplay";
8
+ import { isSingleFreeTicketEvent, summarizeTicketPrices } from "../format/ticketPrice";
8
9
  import { describeBookingFee } from "../format/bookingFee";
9
10
  import { usePricesIncludeTax } from "../../data/queries/useWebsite";
10
11
  import { Loading } from "./Loading";
@@ -82,7 +83,9 @@ export function EventDetail({
82
83
  if (!event) return <p>{tr("forge.event_detail.not_found")}</p>;
83
84
 
84
85
  const cover = event.media?.find((m) => m.type === "image")?.url ?? event.media?.[0]?.url;
85
- const minPrice = event.tickets.length ? Math.min(...event.tickets.map((t) => t.price)) : 0;
86
+ // "Free", "Free to $40" or "From $25": a zero-priced tier never reads "$0.00".
87
+ const priceSummary = summarizeTicketPrices(event.tickets);
88
+ const taxCaption = priceTaxCaption({ pricesIncludeTax, formatAmount: fmt });
86
89
  const feeNote = describeBookingFee(event.bookingFee, fmt);
87
90
  const locationText =
88
91
  event.type === "virtual" || !event.address
@@ -181,9 +184,24 @@ export function EventDetail({
181
184
  )}
182
185
  {event.tickets.length > 0 && (
183
186
  <>
184
- <span style={{ fontWeight: 600 }}>
185
- {tr("forge.event_detail.price_from_label")}{" "}
186
- <PriceDisplay amount={minPrice} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} inlineCaption />
187
+ <span data-testid="event-price-summary" style={{ fontWeight: 600 }}>
188
+ {priceSummary?.kind === "free" ? (
189
+ tr("forge.event_detail.price_free")
190
+ ) : priceSummary?.kind === "free_to" ? (
191
+ <>
192
+ {tr("forge.event_detail.price_free_to", { max: fmt(priceSummary.max) })}
193
+ {taxCaption && (
194
+ <span style={{ fontSize: "0.72em", fontWeight: 400, marginLeft: "0.35em", whiteSpace: "nowrap", color: t.text, opacity: 0.65 }}>
195
+ {taxCaption}
196
+ </span>
197
+ )}
198
+ </>
199
+ ) : (
200
+ <>
201
+ {tr("forge.event_detail.price_from_label")}{" "}
202
+ <PriceDisplay amount={priceSummary?.min ?? 0} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} inlineCaption />
203
+ </>
204
+ )}
187
205
  </span>
188
206
  {/* The cheapest tier is the number a buyer anchors on, and until
189
207
  now it was the whole story they were given — the artist's
@@ -201,7 +219,12 @@ export function EventDetail({
201
219
  {event.tickets.length > 0 && (
202
220
  <EventTickets
203
221
  slug={slug}
204
- triggerText={tr("forge.event_detail.buy_tickets")}
222
+ // One free tier is a reservation, not a purchase.
223
+ triggerText={
224
+ isSingleFreeTicketEvent(event.tickets)
225
+ ? tr("forge.event_detail.reserve")
226
+ : tr("forge.event_detail.buy_tickets")
227
+ }
205
228
  formatAmount={fmt}
206
229
  finalisePath={finalisePath}
207
230
  onComplete={onComplete}
@@ -7,6 +7,7 @@ import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
7
7
  import { useAmountFormatter } from "../format/useFormatCurrency";
8
8
  import { PriceDisplay, summarizeTaxQuote } from "../format/PriceDisplay";
9
9
  import { clampChosenAmount, isPayWhatYouWant, pwywDefaultAmount, pwywMaximum } from "../format/pwyw";
10
+ import { isFreeTicket, isSingleFreeTicketEvent } from "../format/ticketPrice";
10
11
  import {
11
12
  maxAddableTickets,
12
13
  onSaleBadge,
@@ -119,7 +120,12 @@ export function EventTickets({
119
120
  return (
120
121
  <DialogRoot open={open} onOpenChange={tryClose}>
121
122
  <DialogTrigger asChild>
122
- <button style={triggerButtonStyle(theme)}>{triggerText ?? t("forge.event_tickets.trigger")}</button>
123
+ <button style={triggerButtonStyle(theme)}>
124
+ {triggerText ??
125
+ (isSingleFreeTicketEvent(c.event?.tickets)
126
+ ? t("forge.event_tickets.reserve")
127
+ : t("forge.event_tickets.trigger"))}
128
+ </button>
123
129
  </DialogTrigger>
124
130
  <DialogPortal>
125
131
  <DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }} />
@@ -603,13 +609,19 @@ function TicketStep({
603
609
  {t("forge.event_tickets.price_from")}
604
610
  </span>
605
611
  )}
606
- <PriceDisplay
607
- amount={Number(ticket.price)}
608
- pricesIncludeTax={pricesIncludeTax}
609
- formatAmount={fmt}
610
- mutedColor={theme.colors.text}
611
- captionStyle={{ opacity: 0.65 }}
612
- />
612
+ {/* A zero-priced tier is free, and "$0.00" reads as a price.
613
+ A PWYW tier with a zero floor keeps its "From" wording. */}
614
+ {isFreeTicket(ticket) ? (
615
+ <span data-testid="ticket-price-free">{t("forge.event_tickets.free")}</span>
616
+ ) : (
617
+ <PriceDisplay
618
+ amount={Number(ticket.price)}
619
+ pricesIncludeTax={pricesIncludeTax}
620
+ formatAmount={fmt}
621
+ mutedColor={theme.colors.text}
622
+ captionStyle={{ opacity: 0.65 }}
623
+ />
624
+ )}
613
625
  </div>
614
626
  </div>
615
627
  <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
@@ -1024,7 +1036,7 @@ function DetailsStep({
1024
1036
  ? t("forge.event_tickets.request_tickets")
1025
1037
  : c.totalAmount > 0
1026
1038
  ? t("forge.event_tickets.continue_payment")
1027
- : t("forge.event_tickets.confirm")
1039
+ : t("forge.event_tickets.reserve")
1028
1040
  }
1029
1041
  nextDisabled={c.isProcessing}
1030
1042
  />
@@ -1132,7 +1144,7 @@ function PaymentStep({
1132
1144
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
1133
1145
  <span style={{ fontSize: 16 }}>{t("forge.event_tickets.total_amount")}</span>
1134
1146
  <span style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary, textAlign: "right" }}>
1135
- {fmt(c.totalAmount)}
1147
+ {totalText(c, fmt, t)}
1136
1148
  {taxSummary?.mode === "inclusive" && (
1137
1149
  <span
1138
1150
  style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}
@@ -1245,6 +1257,16 @@ function BookingFeeLine({ c, fmt }: { c: Checkout; fmt: (n: number) => string })
1245
1257
  }
1246
1258
 
1247
1259
  // ── Shared bits ───────────────────────────────────────────────────────────────
1260
+
1261
+ /**
1262
+ * The order total as the buyer reads it. A selection that costs nothing (free
1263
+ * tiers, no booking fee, no paid add-ons) says "Free" rather than "$0.00". An
1264
+ * EMPTY selection keeps the formatted zero: on a paid event, nothing chosen yet
1265
+ * is not the same as free.
1266
+ */
1267
+ function totalText(c: Checkout, fmt: (n: number) => string, t: (key: string) => string): string {
1268
+ return c.totalAmount === 0 && c.ticketCount > 0 ? t("forge.event_tickets.free") : fmt(c.totalAmount);
1269
+ }
1248
1270
  function ActionBar({
1249
1271
  c,
1250
1272
  fmt,
@@ -1291,7 +1313,9 @@ function ActionBar({
1291
1313
  <BookingFeeLine c={c} fmt={fmt} />
1292
1314
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
1293
1315
  <span style={{ fontWeight: 600 }}>{t("forge.event_tickets.total")}</span>
1294
- <span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(c.totalAmount)}</span>
1316
+ <span data-testid="tickets-total" style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>
1317
+ {totalText(c, fmt, t)}
1318
+ </span>
1295
1319
  </div>
1296
1320
  </div>
1297
1321
  <div style={{ display: "flex", gap: 8, justifyContent: "space-between" }}>
@@ -9,6 +9,7 @@ import { useThemeTokens } from "../theme/ForgeThemeProvider";
9
9
  import { PageActions } from "./PageActions";
10
10
  import { DEFAULT_MUSIC_LINK_STYLE, MusicLinkLayout, type LayoutParts, type MusicLinkStyle } from "./musicLinkStyles";
11
11
  import { useCookieConsent } from "../headless/consent/useCookieConsent";
12
+ import { useBrandLegalUrls } from "../../data/queries/useWebsite";
12
13
 
13
14
  /**
14
15
  * How each streaming service is presented.
@@ -57,7 +58,7 @@ const SERVICES: Record<string, { label: string; color: string; action?: string }
57
58
  */
58
59
  const OVERLAY_Z_INDEX = 60;
59
60
 
60
- /** TribeNest's hosted policy, matching what `CookieConsent` falls back to. */
61
+ /** TribeNest's hosted policy, the last fallback, matching `CookieConsent`. */
61
62
  const TRIBENEST_PRIVACY_URL = "https://www.tribenest.co/privacy";
62
63
 
63
64
  const serviceMeta = (platform: string) => SERVICES[platform] ?? { label: platform, color: "#666666", action: "Open" };
@@ -69,7 +70,8 @@ export interface MusicLinkPageProps {
69
70
  initialLink?: IMusicLink | null;
70
71
  /** Heading above the service list. */
71
72
  listHeading?: string;
72
- /** The site's own privacy policy. Falls back to TribeNest's hosted one. */
73
+ /** The site's own privacy policy. Falls back to the brand's hosted one
74
+ * (TribeNest's, or Mandatera's for a law firm) from `site-config`. */
73
75
  privacyHref?: string;
74
76
  /** The "Powered by TribeNest" credit. */
75
77
  showPoweredBy?: boolean;
@@ -375,7 +377,8 @@ function SmartLinkFooter({
375
377
  showPoweredBy: boolean;
376
378
  }) {
377
379
  const { reopen } = useCookieConsent();
378
- const href = privacyHref ?? TRIBENEST_PRIVACY_URL;
380
+ const brandLegal = useBrandLegalUrls();
381
+ const href = privacyHref ?? brandLegal?.privacy ?? TRIBENEST_PRIVACY_URL;
379
382
  const external = !privacyHref;
380
383
 
381
384
  const linkStyle: CSSProperties = {
@@ -0,0 +1,299 @@
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
7
+
8
+ /**
9
+ * The booking dialog: a month calendar, day first then time, and a basket for
10
+ * products sold as several sessions.
11
+ *
12
+ * 1. Days with open times are enabled; the rest are not. Times are grouped by
13
+ * the visitor's LOCAL day.
14
+ * 2. Month navigation stops at this month and at the booking window.
15
+ * 3. A package needs exactly its count; Continue says what is missing, then
16
+ * the package price, and reserves every pick in ONE call (earliest first).
17
+ * 4. Per session: picks are capped at the limit and the total follows the count.
18
+ * 5. One session: a second pick replaces the first.
19
+ * 6. On a phone: the month opens first, a day opens its own screen, and the
20
+ * basket rides in the bottom bar.
21
+ *
22
+ * The data hooks are mocked; the REAL hook and components are drawn.
23
+ */
24
+
25
+ const NOW = new Date(2030, 0, 3, 9, 0, 0); // Thu 3 Jan 2030, local
26
+
27
+ const at = (day: number, hour: number, minute = 0, month = 0) => new Date(2030, month, day, hour, minute, 0);
28
+ const slot = (id: string, start: Date) => ({
29
+ id,
30
+ startTime: start.toISOString(),
31
+ endTime: new Date(start.getTime() + 60 * 60_000).toISOString(),
32
+ coachingAvailabilityDayId: "d1",
33
+ });
34
+
35
+ const SLOTS = [
36
+ slot("s-7-10", at(7, 10)),
37
+ slot("s-7-11", at(7, 11)),
38
+ slot("s-7-2330", at(7, 23, 30)), // late evening: must stay on the 7th locally
39
+ slot("s-9-14", at(9, 14)),
40
+ slot("s-14-09", at(14, 9)),
41
+ slot("s-21-16", at(21, 16)),
42
+ slot("s-feb-04", at(4, 10, 0, 1)),
43
+ ];
44
+
45
+ let product: Record<string, unknown> = {};
46
+ const reserveSpy = vi.fn();
47
+ /** Every window the calendar asked for, to catch a query key that changes per render. */
48
+ const windowsAsked: string[] = [];
49
+ const unreserveSpy = vi.fn();
50
+
51
+ vi.mock("../../../data/queries/useCoachingProducts", () => ({
52
+ useGetCoachingProduct: () => ({ data: product, isLoading: false }),
53
+ }));
54
+ vi.mock("../../../data/queries/useCoachingAvailability", () => ({
55
+ useCoachingAvailability: (_id: string, from: string, to: string) => {
56
+ windowsAsked.push(`${from}|${to}`);
57
+ const lo = Date.parse(from);
58
+ const hi = Date.parse(to);
59
+ const data = SLOTS.filter((s) => Date.parse(s.startTime) >= lo && Date.parse(s.startTime) <= hi);
60
+ return { data, isLoading: false, refetch: vi.fn().mockResolvedValue({ data }) };
61
+ },
62
+ useReserveCoachingBooking: () => ({ mutateAsync: reserveSpy, isPending: false }),
63
+ useUpdateCoachingBooking: () => ({ mutateAsync: vi.fn(), isPending: false }),
64
+ useUnreserveCoachingBooking: () => ({ mutateAsync: unreserveSpy, isPending: false }),
65
+ }));
66
+ vi.mock("../../../data/queries/usePaymentFlow", () => ({
67
+ usePaymentFlow: () => ({ start: vi.fn(), isStarting: false, result: null, clientSecret: null }),
68
+ }));
69
+ vi.mock("../../../contexts/PublicAuthContext", () => ({
70
+ usePublicAuth: () => ({ isAuthenticated: false, isInitialized: true, user: null }),
71
+ }));
72
+ vi.mock("../../payment/ForgePaymentProvider", () => ({ usePaymentRenderer: () => null }));
73
+ vi.mock("../../../data/queries/useWebsite", () => ({
74
+ useSiteConfig: () => ({ data: { currency: "USD" }, isLoading: false }),
75
+ usePricesIncludeTax: () => false,
76
+ }));
77
+
78
+ const { CoachingBooking } = await import("../CoachingBooking");
79
+
80
+ const THEME = { colors: { text: "#111111", background: "#ffffff", primary: "#0d6b5f" }, cornerRadius: 8 };
81
+ const fmt = (n: number) => `$${n.toFixed(2)}`;
82
+
83
+ const baseProduct = {
84
+ id: "p1",
85
+ slug: "vocal",
86
+ title: "Vocal coaching",
87
+ description: "",
88
+ price: "80",
89
+ durationMinutes: 60,
90
+ daysInFuture: 40, // last bookable day is 12 Feb 2030
91
+ timezone: "Africa/Lagos",
92
+ bufferMinutes: 0,
93
+ archivedAt: null,
94
+ availabilityDays: [],
95
+ blockedDates: [],
96
+ media: [],
97
+ };
98
+
99
+ let narrow = false;
100
+
101
+ const open = () => {
102
+ render(
103
+ <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
104
+ <ForgeThemeProvider theme={THEME}>
105
+ <CoachingBooking slug="vocal" formatAmount={fmt} triggerText="Book" />
106
+ </ForgeThemeProvider>
107
+ </QueryClientProvider>,
108
+ );
109
+ fireEvent.click(screen.getByRole("button", { name: "Book" }));
110
+ return screen.getByTestId("coaching-booking-dialog");
111
+ };
112
+
113
+ const day = (dialog: HTMLElement, key: string) =>
114
+ within(dialog).getAllByTestId("booking-day").find((el) => el.getAttribute("data-day") === key)!;
115
+ const timeButton = (dialog: HTMLElement, label: string) =>
116
+ within(dialog)
117
+ .getAllByTestId("booking-time")
118
+ .find((el) => el.textContent?.includes(label))!;
119
+ const label = (h: number, m = 0) =>
120
+ new Date(2030, 0, 1, h, m).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
121
+
122
+ beforeEach(() => {
123
+ vi.useFakeTimers({ toFake: ["Date"] });
124
+ vi.setSystemTime(NOW);
125
+ narrow = false;
126
+ window.matchMedia = ((query: string) => ({
127
+ matches: narrow && query.includes("max-width"),
128
+ media: query,
129
+ addEventListener: () => undefined,
130
+ removeEventListener: () => undefined,
131
+ })) as unknown as typeof window.matchMedia;
132
+ reserveSpy.mockReset().mockResolvedValue({ bookingId: "b1", bookingSecret: "x".repeat(40), sessionCount: 1 });
133
+ unreserveSpy.mockReset().mockResolvedValue({ success: true });
134
+ product = { ...baseProduct };
135
+ });
136
+
137
+ afterEach(() => {
138
+ cleanup();
139
+ vi.useRealTimers();
140
+ });
141
+
142
+ describe("month calendar", () => {
143
+ it("enables only days with open times and groups times by the local day", () => {
144
+ const dialog = open();
145
+ expect(within(dialog).getByTestId("booking-month-label").textContent).toMatch(/January/);
146
+ expect(day(dialog, "2030-01-07").getAttribute("data-open")).toBe("true");
147
+ expect(day(dialog, "2030-01-08").getAttribute("data-open")).toBe("false");
148
+ expect((day(dialog, "2030-01-08") as HTMLButtonElement).disabled).toBe(true);
149
+
150
+ // The first open day is shown straight away on a wide screen.
151
+ expect(within(dialog).getByTestId("booking-day-title").textContent).toMatch(/7/);
152
+ const times = within(dialog).getAllByTestId("booking-time").map((b) => b.textContent);
153
+ expect(times).toHaveLength(3);
154
+ expect(times.some((t) => t?.includes(label(23, 30)))).toBe(true);
155
+ });
156
+
157
+ it("asks for the same window on every render while the clock moves (no refetch loop)", () => {
158
+ windowsAsked.length = 0;
159
+ const dialog = open();
160
+ vi.setSystemTime(new Date(NOW.getTime() + 5_000));
161
+ fireEvent.click(timeButton(dialog, label(10)));
162
+ vi.setSystemTime(new Date(NOW.getTime() + 9_000));
163
+ fireEvent.click(timeButton(dialog, label(11)));
164
+ expect(new Set(windowsAsked).size).toBe(1);
165
+ });
166
+
167
+ it("stops at this month and at the end of the booking window", () => {
168
+ const dialog = open();
169
+ const prev = within(dialog).getByTestId("booking-prev-month") as HTMLButtonElement;
170
+ const next = within(dialog).getByTestId("booking-next-month") as HTMLButtonElement;
171
+ expect(prev.disabled).toBe(true);
172
+ expect(next.disabled).toBe(false);
173
+ fireEvent.click(next);
174
+ expect(within(dialog).getByTestId("booking-month-label").textContent).toMatch(/February/);
175
+ expect(day(dialog, "2030-02-04").getAttribute("data-open")).toBe("true");
176
+ expect((within(dialog).getByTestId("booking-next-month") as HTMLButtonElement).disabled).toBe(true);
177
+ expect((within(dialog).getByTestId("booking-prev-month") as HTMLButtonElement).disabled).toBe(false);
178
+ });
179
+ });
180
+
181
+ describe("package of 3", () => {
182
+ beforeEach(() => {
183
+ product = { ...baseProduct, pricingMode: "package", packageSessionCount: 3, packagePrice: "200" };
184
+ });
185
+
186
+ it("needs exactly three, keeps picks across months, and reserves them in one call", async () => {
187
+ const dialog = open();
188
+ const cont = () => within(dialog).getByTestId("booking-continue") as HTMLButtonElement;
189
+ expect(cont().disabled).toBe(true);
190
+ expect(cont().textContent).toBe("Pick 3 more");
191
+
192
+ fireEvent.click(timeButton(dialog, label(11)));
193
+ expect(cont().textContent).toBe("Pick 2 more");
194
+ expect(within(day(dialog, "2030-01-07")).getByTestId("booking-day-picks").textContent).toBe("1");
195
+
196
+ fireEvent.click(day(dialog, "2030-01-14"));
197
+ fireEvent.click(timeButton(dialog, label(9)));
198
+
199
+ // A pick in February keeps the January ones.
200
+ fireEvent.click(within(dialog).getByTestId("booking-next-month"));
201
+ fireEvent.click(day(dialog, "2030-02-04"));
202
+ fireEvent.click(timeButton(dialog, label(10)));
203
+
204
+ expect(within(dialog).getAllByTestId("booking-basket-item")).toHaveLength(3);
205
+ expect(cont().disabled).toBe(false);
206
+ expect(cont().textContent).toBe("Continue, $200.00");
207
+ // The basket is full: a fourth time cannot be added.
208
+ fireEvent.click(within(dialog).getByTestId("booking-prev-month"));
209
+ fireEvent.click(day(dialog, "2030-01-21"));
210
+ expect((timeButton(dialog, label(16)) as HTMLButtonElement).disabled).toBe(true);
211
+
212
+ await act(async () => {
213
+ fireEvent.click(cont());
214
+ });
215
+ expect(reserveSpy).toHaveBeenCalledTimes(1);
216
+ expect(reserveSpy).toHaveBeenCalledWith({ slotIds: ["s-7-11", "s-14-09", "s-feb-04"] });
217
+ });
218
+
219
+ it("Back from the details step releases the hold WITH its secret and keeps the basket", async () => {
220
+ const dialog = open();
221
+ fireEvent.click(timeButton(dialog, label(10)));
222
+ fireEvent.click(timeButton(dialog, label(11)));
223
+ fireEvent.click(day(dialog, "2030-01-09"));
224
+ fireEvent.click(timeButton(dialog, label(14)));
225
+ await act(async () => {
226
+ fireEvent.click(within(dialog).getByTestId("booking-continue"));
227
+ });
228
+ await act(async () => {
229
+ fireEvent.click(within(dialog).getByRole("button", { name: "Back" }));
230
+ });
231
+ expect(unreserveSpy).toHaveBeenCalledWith({ bookingId: "b1", bookingSecret: "x".repeat(40) });
232
+ expect(within(dialog).getAllByTestId("booking-basket-item")).toHaveLength(3);
233
+ });
234
+
235
+ it("lets a pick be removed from the basket", () => {
236
+ const dialog = open();
237
+ fireEvent.click(timeButton(dialog, label(10)));
238
+ fireEvent.click(timeButton(dialog, label(11)));
239
+ expect(within(dialog).getAllByTestId("booking-basket-item")).toHaveLength(2);
240
+ const first = within(dialog).getAllByTestId("booking-basket-item")[0]!;
241
+ fireEvent.click(within(first).getByRole("button"));
242
+ expect(within(dialog).getAllByTestId("booking-basket-item")).toHaveLength(1);
243
+ });
244
+ });
245
+
246
+ describe("per session, up to 2", () => {
247
+ beforeEach(() => {
248
+ product = { ...baseProduct, pricingMode: "per_session", maxSessionsPerBooking: 2 };
249
+ });
250
+
251
+ it("caps the picks and prices by the count", () => {
252
+ const dialog = open();
253
+ const cont = () => within(dialog).getByTestId("booking-continue") as HTMLButtonElement;
254
+ fireEvent.click(timeButton(dialog, label(10)));
255
+ expect(cont().textContent).toBe("Continue, $80.00");
256
+ fireEvent.click(timeButton(dialog, label(11)));
257
+ expect(cont().textContent).toBe("Continue, $160.00");
258
+ expect(within(dialog).getByRole("status").textContent).toMatch(/2 session limit/);
259
+ expect((timeButton(dialog, label(23, 30)) as HTMLButtonElement).disabled).toBe(true);
260
+ });
261
+ });
262
+
263
+ describe("one session", () => {
264
+ it("replaces the pick and reserves the single slot as before", async () => {
265
+ const dialog = open();
266
+ fireEvent.click(timeButton(dialog, label(10)));
267
+ fireEvent.click(timeButton(dialog, label(11)));
268
+ expect(within(dialog).getAllByTestId("booking-basket-item")).toHaveLength(1);
269
+ await act(async () => {
270
+ fireEvent.click(within(dialog).getByTestId("booking-continue"));
271
+ });
272
+ expect(reserveSpy).toHaveBeenCalledWith({ slotId: "s-7-11" });
273
+ });
274
+ });
275
+
276
+ describe("on a phone", () => {
277
+ beforeEach(() => {
278
+ narrow = true;
279
+ product = { ...baseProduct, pricingMode: "package", packageSessionCount: 2, packagePrice: "150" };
280
+ });
281
+
282
+ it("opens on the month, gives each day its own screen and keeps the basket in the bar", () => {
283
+ const dialog = open();
284
+ expect(within(dialog).queryByTestId("booking-times")).toBeNull();
285
+ expect(within(dialog).getByTestId("booking-next-available").textContent).toMatch(/7/);
286
+
287
+ fireEvent.click(within(dialog).getByTestId("booking-next-available"));
288
+ expect(within(dialog).getByTestId("booking-day-strip")).toBeTruthy();
289
+ fireEvent.click(timeButton(dialog, label(10)));
290
+ expect(within(dialog).getByTestId("booking-basket-toggle").textContent).toMatch(/1 of 2 picked/);
291
+
292
+ fireEvent.click(within(dialog).getByTestId("booking-basket-toggle"));
293
+ const sheet = screen.getByTestId("booking-basket-sheet");
294
+ expect(within(sheet).getByText(/Not picked yet/)).toBeTruthy();
295
+
296
+ fireEvent.click(within(dialog).getByTestId("booking-back-to-month"));
297
+ expect(within(dialog).getByTestId("booking-calendar")).toBeTruthy();
298
+ });
299
+ });