@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
@@ -0,0 +1,242 @@
1
+ // @vitest-environment jsdom
2
+ import { 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
+ * Free event tickets read as free, on the event page and in the ticket modal.
10
+ *
11
+ * 1. A zero-priced tier (not PWYW) says "Free", never "$0.00".
12
+ * 2. The page summary: all free is "Free", free and paid is "Free to $X",
13
+ * all paid keeps "From $X".
14
+ * 3. One free tier makes the call to action "Reserve", not "Buy tickets".
15
+ * 4. A selection that costs nothing totals "Free", and the details step's
16
+ * final button says "Reserve" rather than "Confirm".
17
+ *
18
+ * The page reads the event through `usePresaleCode` and the modal runs on
19
+ * `useEventCheckout`, so both are mocked and the REAL components are drawn.
20
+ */
21
+
22
+ type Tier = { id: string; title: string; price: number; payWhatYouWant?: boolean };
23
+
24
+ const tier = (id: string, price: number, extra: Partial<Tier> = {}): Tier => ({
25
+ id,
26
+ title: `Tier ${id}`,
27
+ price,
28
+ quantity: 100,
29
+ sold: 0,
30
+ held: 0,
31
+ ...extra,
32
+ }) as Tier;
33
+
34
+ const eventWith = (tickets: Tier[]) => ({
35
+ id: "e1",
36
+ slug: "launch",
37
+ title: "Launch night",
38
+ dateTime: "2030-01-01T19:00:00.000Z",
39
+ type: "virtual",
40
+ status: "upcoming",
41
+ media: [],
42
+ tickets,
43
+ });
44
+
45
+ const presaleState: Record<string, unknown> = {};
46
+ const checkoutState: Record<string, unknown> = {};
47
+
48
+ vi.mock("../../headless/event/usePresaleCode", () => ({
49
+ usePresaleCode: () => presaleState,
50
+ }));
51
+ vi.mock("../../headless/event/useEventCheckout", () => ({
52
+ useEventCheckout: () => checkoutState,
53
+ }));
54
+ vi.mock("../../../data/queries/usePageActions", () => ({
55
+ usePageActions: () => ({ data: undefined }),
56
+ }));
57
+ vi.mock("../../../data/queries/useWebsite", () => ({
58
+ useSiteConfig: () => ({ data: { currency: "USD" }, isLoading: false }),
59
+ usePricesIncludeTax: () => false,
60
+ }));
61
+ vi.mock("../../../contexts/PublicAuthContext", () => ({
62
+ usePublicAuth: () => ({ isAuthenticated: false, isInitialized: true, user: null }),
63
+ }));
64
+ // Neither is about price, and both reach queries this spec has no server for.
65
+ vi.mock("../EventWaitlist", () => ({ EventWaitlist: () => null }));
66
+ vi.mock("../MembershipGateNotice", () => ({ MembershipGateNotice: () => null }));
67
+
68
+ const { EventDetail } = await import("../EventDetail");
69
+
70
+ const THEME = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
71
+
72
+ const wrap = (node: React.ReactNode) => (
73
+ <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
74
+ <ForgeThemeProvider theme={THEME}>{node}</ForgeThemeProvider>
75
+ </QueryClientProvider>
76
+ );
77
+
78
+ const fmt = (n: number) => `$${n.toFixed(2)}`;
79
+
80
+ const setEvent = (tickets: Tier[]) => {
81
+ const event = eventWith(tickets);
82
+ Object.keys(presaleState).forEach((k) => delete presaleState[k]);
83
+ Object.assign(presaleState, {
84
+ event,
85
+ isLoading: false,
86
+ visible: false,
87
+ input: "",
88
+ setInput: vi.fn(),
89
+ code: null,
90
+ status: "idle",
91
+ message: null,
92
+ submit: vi.fn(),
93
+ clear: vi.fn(),
94
+ isUnlocked: false,
95
+ });
96
+ Object.keys(checkoutState).forEach((k) => delete checkoutState[k]);
97
+ Object.assign(checkoutState, {
98
+ event,
99
+ isLoading: false,
100
+ step: "tickets",
101
+ setStep: vi.fn(),
102
+ presale: presaleState,
103
+ seating: { availableByTier: {}, seatedTierIds: new Set(), byId: new Map() },
104
+ selectedTickets: {},
105
+ selectedSeats: {},
106
+ seatSummary: [],
107
+ reservedForYou: {},
108
+ pwywAmounts: {},
109
+ setTicketQty: vi.fn(),
110
+ setTicketAmount: vi.fn(),
111
+ isSeatedTier: () => false,
112
+ isTierBlockedByApprovalMix: () => false,
113
+ awaitingApproval: false,
114
+ ticketsInCart: false,
115
+ addTicketsToCart: vi.fn(),
116
+ goToDetails: vi.fn(),
117
+ error: null,
118
+ gateRefusal: null,
119
+ coupon: { discountAmount: 0, appliedCoupons: [], hasDiscount: false },
120
+ subTotal: 0,
121
+ totalAmount: 0,
122
+ ticketCount: 0,
123
+ bookingFeeAmount: 0,
124
+ firstName: "",
125
+ setFirstName: vi.fn(),
126
+ lastName: "",
127
+ setLastName: vi.fn(),
128
+ email: "",
129
+ setEmail: vi.fn(),
130
+ setQuestionnaire: vi.fn(),
131
+ attendeeSlots: [],
132
+ setAttendee: vi.fn(),
133
+ continueToPayment: vi.fn(),
134
+ isProcessing: false,
135
+ });
136
+ };
137
+
138
+ const draw = () => render(wrap(<EventDetail slug="launch" formatAmount={fmt} />));
139
+ const summary = () => screen.getByTestId("event-price-summary").textContent;
140
+
141
+ beforeEach(() => setEvent([tier("a", 25)]));
142
+ afterEach(cleanup);
143
+
144
+ describe("EventDetail price summary and call to action", () => {
145
+ it("one free tier reads Free and the button says Reserve", () => {
146
+ setEvent([tier("a", 0)]);
147
+ draw();
148
+ expect(summary()).toBe("Free");
149
+ expect(summary()).not.toContain("$0.00");
150
+ expect(screen.getByRole("button", { name: "Reserve" })).toBeTruthy();
151
+ expect(screen.queryByRole("button", { name: "Buy tickets" })).toBeNull();
152
+ });
153
+
154
+ it("free and paid tiers read Free to the dearest, and the button stays Buy tickets", () => {
155
+ setEvent([tier("a", 0), tier("b", 25), tier("b2", 40)]);
156
+ draw();
157
+ expect(summary()).toBe("Free to $40.00");
158
+ expect(screen.getByRole("button", { name: "Buy tickets" })).toBeTruthy();
159
+ });
160
+
161
+ it("two free tiers read Free but are still a choice: Buy tickets", () => {
162
+ setEvent([tier("a", 0), tier("b", 0)]);
163
+ draw();
164
+ expect(summary()).toBe("Free");
165
+ expect(screen.getByRole("button", { name: "Buy tickets" })).toBeTruthy();
166
+ });
167
+
168
+ it("all paid tiers keep From the cheapest and Buy tickets", () => {
169
+ setEvent([tier("a", 40), tier("b", 25)]);
170
+ draw();
171
+ expect(summary()).toBe("From $25.00");
172
+ expect(screen.getByRole("button", { name: "Buy tickets" })).toBeTruthy();
173
+ });
174
+
175
+ it("a zero-floor PWYW tier is not free: From and Buy tickets", () => {
176
+ setEvent([tier("a", 0, { payWhatYouWant: true })]);
177
+ draw();
178
+ expect(summary()).toBe("From $0.00");
179
+ expect(screen.getByRole("button", { name: "Buy tickets" })).toBeTruthy();
180
+ });
181
+ });
182
+
183
+ describe("EventTickets modal", () => {
184
+ it("labels a free tier Free and a zero-cost selection's total Free", () => {
185
+ setEvent([tier("a", 0)]);
186
+ Object.assign(checkoutState, { selectedTickets: { a: 1 }, ticketCount: 1, totalAmount: 0 });
187
+ draw();
188
+ fireEvent.click(screen.getByRole("button", { name: "Reserve" }));
189
+ const dialog = screen.getByRole("dialog");
190
+ expect(within(dialog).getByTestId("ticket-price-free").textContent).toBe("Free");
191
+ expect(within(dialog).getByTestId("tickets-total").textContent).toBe("Free");
192
+ expect(dialog.textContent).not.toContain("$0.00");
193
+ });
194
+
195
+ it("keeps the formatted zero on an EMPTY selection: nothing chosen is not free", () => {
196
+ setEvent([tier("a", 25)]);
197
+ draw();
198
+ fireEvent.click(screen.getByRole("button", { name: "Buy tickets" }));
199
+ expect(within(screen.getByRole("dialog")).getByTestId("tickets-total").textContent).toBe("$0.00");
200
+ });
201
+
202
+ it("the details step's final button says Reserve when the total is zero", () => {
203
+ setEvent([tier("a", 0)]);
204
+ Object.assign(checkoutState, { step: "details", selectedTickets: { a: 1 }, ticketCount: 1, totalAmount: 0 });
205
+ draw();
206
+ fireEvent.click(screen.getByRole("button", { name: "Reserve" }));
207
+ const dialog = screen.getByRole("dialog");
208
+ expect(within(dialog).getByRole("button", { name: "Reserve" })).toBeTruthy();
209
+ expect(within(dialog).queryByRole("button", { name: "Confirm" })).toBeNull();
210
+ expect(within(dialog).getByTestId("tickets-total").textContent).toBe("Free");
211
+ });
212
+
213
+ it("a paid selection still continues to payment with a priced total", () => {
214
+ setEvent([tier("a", 25)]);
215
+ Object.assign(checkoutState, {
216
+ step: "details",
217
+ selectedTickets: { a: 2 },
218
+ ticketCount: 2,
219
+ totalAmount: 50,
220
+ subTotal: 50,
221
+ });
222
+ draw();
223
+ fireEvent.click(screen.getByRole("button", { name: "Buy tickets" }));
224
+ const dialog = screen.getByRole("dialog");
225
+ expect(within(dialog).getByRole("button", { name: "Continue to payment" })).toBeTruthy();
226
+ expect(within(dialog).getByTestId("tickets-total").textContent).toBe("$50.00");
227
+ });
228
+
229
+ it("a free ticket REQUEST keeps its Request tickets wording", () => {
230
+ setEvent([tier("a", 0)]);
231
+ Object.assign(checkoutState, {
232
+ step: "details",
233
+ selectedTickets: { a: 1 },
234
+ ticketCount: 1,
235
+ totalAmount: 0,
236
+ awaitingApproval: true,
237
+ });
238
+ draw();
239
+ fireEvent.click(screen.getByRole("button", { name: "Reserve" }));
240
+ expect(within(screen.getByRole("dialog")).getByRole("button", { name: "Request tickets" })).toBeTruthy();
241
+ });
242
+ });
@@ -3,7 +3,7 @@ import { useEvent } from "../../../data/queries/useEvents";
3
3
  import { useForgeT } from "../../../i18n";
4
4
  import { useForgeTheme } from "../../theme/ForgeThemeProvider";
5
5
  import { useAmountFormatter } from "../../format/useFormatCurrency";
6
- import { minTicketPrice } from "../../headless/broadcast/broadcastState";
6
+ import { isSingleFreeTicketEvent, summarizeTicketPrices } from "../../format/ticketPrice";
7
7
  import { EventTickets } from "../EventTickets";
8
8
 
9
9
  export interface BroadcastTicketPurchaseProps {
@@ -40,7 +40,9 @@ export function BroadcastTicketPurchase({
40
40
 
41
41
  if (!event) return null;
42
42
 
43
- const from = minTicketPrice(broadcast);
43
+ // "Free", "Free to $40" or "From $25", the same wording as the event page.
44
+ const tickets = broadcast.events?.[0]?.eventTickets;
45
+ const summary = summarizeTicketPrices(tickets);
44
46
 
45
47
  return (
46
48
  <div
@@ -61,12 +63,24 @@ export function BroadcastTicketPurchase({
61
63
  >
62
64
  <EventTickets
63
65
  slug={event.slug}
64
- triggerText={t("forge.broadcast_ticket_purchase.buy_tickets")}
66
+ triggerText={
67
+ isSingleFreeTicketEvent(tickets)
68
+ ? t("forge.broadcast_ticket_purchase.reserve")
69
+ : t("forge.broadcast_ticket_purchase.buy_tickets")
70
+ }
65
71
  formatAmount={fmt}
66
72
  finalisePath={finalisePath}
67
73
  onComplete={onComplete}
68
74
  />
69
- {from !== null && <p>{t("forge.broadcast_ticket_purchase.from_price", { price: fmt(from) })}</p>}
75
+ {summary && (
76
+ <p data-testid="broadcast-ticket-price">
77
+ {summary.kind === "free"
78
+ ? t("forge.broadcast_ticket_purchase.free")
79
+ : summary.kind === "free_to"
80
+ ? t("forge.broadcast_ticket_purchase.free_to_price", { price: fmt(summary.max) })
81
+ : t("forge.broadcast_ticket_purchase.from_price", { price: fmt(summary.min) })}
82
+ </p>
83
+ )}
70
84
  </div>
71
85
  );
72
86
  }
@@ -928,11 +928,6 @@
928
928
  grid-template-columns: repeat(2, minmax(0, 1fr));
929
929
  }
930
930
  }
931
- .fg\:md\:grid-cols-4 {
932
- @media (width >= 48rem) {
933
- grid-template-columns: repeat(4, minmax(0, 1fr));
934
- }
935
- }
936
931
  .fg\:md\:flex-row {
937
932
  @media (width >= 48rem) {
938
933
  flex-direction: row;
@@ -10,6 +10,7 @@ import { Loading } from "../Loading";
10
10
  import { SeatMapCanvas } from "./SeatMapCanvas";
11
11
  import { SeatLegend, type SeatLegendTier } from "./SeatLegend";
12
12
  import { tierColorMap } from "./paint";
13
+ import { isFreeTicket } from "../../format/ticketPrice";
13
14
 
14
15
  export interface EventSeatPickerProps {
15
16
  /** The event whose tiers price the seats. Tier order fixes the palette. */
@@ -84,6 +85,7 @@ export function EventSeatPicker({
84
85
  id: ticket.id,
85
86
  title: ticket.title,
86
87
  price: Number(ticket.price),
88
+ free: isFreeTicket(ticket),
87
89
  color: tierColors[ticket.id]!,
88
90
  left: seating.availableByTier[ticket.id] ?? 0,
89
91
  })),
@@ -7,6 +7,8 @@ export interface SeatLegendTier {
7
7
  title: string;
8
8
  /** Per-unit price, in the artist's currency. */
9
9
  price: number;
10
+ /** A free tier (zero price, not PWYW): the chip reads "Free", not "$0.00". */
11
+ free?: boolean;
10
12
  /** The tier's colour on the map. */
11
13
  color: string;
12
14
  /** Seats still available on this tier. */
@@ -40,7 +42,7 @@ export function SeatLegend({ tiers, formatAmount, className }: SeatLegendProps)
40
42
  <li key={tier.id} data-testid="seat-legend-tier" className="fg:flex fg:items-center fg:gap-2">
41
43
  <Swatch color={tier.color} />
42
44
  <span style={{ fontWeight: 600 }}>{tier.title}</span>
43
- <span style={{ opacity: 0.8 }}>{formatAmount(tier.price)}</span>
45
+ <span style={{ opacity: 0.8 }}>{tier.free ? t("forge.seat_legend.free") : formatAmount(tier.price)}</span>
44
46
  <span style={{ opacity: 0.65 }}>
45
47
  {tier.left === 0
46
48
  ? t("forge.seat_legend.sold_out")
@@ -435,6 +435,9 @@ export interface ServiceSchemaSource {
435
435
  description?: string | null;
436
436
  media?: { url: string; type?: string }[] | null;
437
437
  price?: number | string | null;
438
+ /** A package is offered at its package price, not the single-session one. */
439
+ pricingMode?: string | null;
440
+ packagePrice?: number | string | null;
438
441
  reviewAggregate?: ReviewSchemaAggregate | null;
439
442
  }
440
443
 
@@ -444,7 +447,7 @@ export function buildServiceSchema(
444
447
  options: SellableSchemaOptions = {},
445
448
  ): Record<string, unknown> | null {
446
449
  if (!service?.title) return null;
447
- const price = toPrice(service.price);
450
+ const price = toPrice(service.pricingMode === "package" ? service.packagePrice : service.price);
448
451
  return buildEntitySchema({
449
452
  type: "Service",
450
453
  name: service.title,