@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.
- package/package.json +1 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -6
- package/src/data/queries/useMyBookings.ts +7 -0
- package/src/i18n/de.json +46 -0
- package/src/i18n/en.json +46 -0
- package/src/types/models.ts +13 -0
- package/src/ui/format/_tests/ticketPrice.spec.ts +58 -0
- package/src/ui/format/bookingPricing.ts +55 -0
- package/src/ui/format/ticketPrice.ts +52 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +139 -13
- package/src/ui/index.ts +13 -0
- package/src/ui/styled/AccountDashboard.tsx +11 -3
- package/src/ui/styled/BookingPriceSummary.tsx +57 -0
- package/src/ui/styled/CoachingBooking.tsx +36 -158
- package/src/ui/styled/CoachingConfirmation.tsx +13 -2
- package/src/ui/styled/CoachingDetail.tsx +8 -2
- package/src/ui/styled/CoachingSlotPicker.tsx +874 -0
- package/src/ui/styled/EventDetail.tsx +29 -6
- package/src/ui/styled/EventTickets.tsx +35 -11
- package/src/ui/styled/_tests/CoachingMultiSession.spec.tsx +299 -0
- package/src/ui/styled/_tests/FreeTickets.spec.tsx +242 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +18 -4
- package/src/ui/styled/forge-utilities.css +0 -5
- package/src/ui/styled/seating/EventSeatPicker.tsx +2 -0
- package/src/ui/styled/seating/SeatLegend.tsx +3 -1
- package/src/utils/structuredData.ts +4 -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
|
-
|
|
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
|
-
{
|
|
186
|
-
|
|
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
|
-
|
|
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)}>
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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.
|
|
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
|
-
{
|
|
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 }}>
|
|
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
|
+
});
|