@tribe-nest/forge 3.63.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.
Files changed (38) 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/i18n/de.json +46 -0
  5. package/src/i18n/en.json +46 -0
  6. package/src/runtime/pages.ts +2 -0
  7. package/src/seo/_tests/seo.spec.ts +202 -6
  8. package/src/seo/applySeoToHead.ts +7 -1
  9. package/src/seo/index.ts +1 -0
  10. package/src/seo/markdown.ts +76 -0
  11. package/src/seo/resolve.ts +46 -3
  12. package/src/seo/seoHead.ts +6 -0
  13. package/src/seo/siteSeoHead.ts +24 -7
  14. package/src/seo/types.ts +15 -1
  15. package/src/server/_tests/buildPwaHead.spec.ts +18 -1
  16. package/src/server/pwa.ts +16 -1
  17. package/src/types/models.ts +13 -0
  18. package/src/ui/format/_tests/ticketPrice.spec.ts +58 -0
  19. package/src/ui/format/bookingPricing.ts +55 -0
  20. package/src/ui/format/ticketPrice.ts +52 -0
  21. package/src/ui/headless/coaching/useCoachingBooking.ts +139 -13
  22. package/src/ui/index.ts +13 -0
  23. package/src/ui/styled/AccountDashboard.tsx +11 -3
  24. package/src/ui/styled/BookingPriceSummary.tsx +57 -0
  25. package/src/ui/styled/CoachingBooking.tsx +36 -158
  26. package/src/ui/styled/CoachingConfirmation.tsx +13 -2
  27. package/src/ui/styled/CoachingDetail.tsx +8 -2
  28. package/src/ui/styled/CoachingSlotPicker.tsx +874 -0
  29. package/src/ui/styled/EventDetail.tsx +29 -6
  30. package/src/ui/styled/EventTickets.tsx +35 -11
  31. package/src/ui/styled/_tests/CoachingMultiSession.spec.tsx +299 -0
  32. package/src/ui/styled/_tests/FreeTickets.spec.tsx +242 -0
  33. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +18 -4
  34. package/src/ui/styled/forge-utilities.css +0 -5
  35. package/src/ui/styled/seating/EventSeatPicker.tsx +2 -0
  36. package/src/ui/styled/seating/SeatLegend.tsx +3 -1
  37. package/src/utils/_tests/structuredDataSeo.spec.ts +36 -0
  38. package/src/utils/structuredData.ts +28 -1
@@ -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" }}>
@@ -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
+ });