@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
@@ -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")
@@ -2,11 +2,17 @@ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  buildBlogPostingSchema,
4
4
  buildBreadcrumbSchema,
5
+ buildCourseSchema,
5
6
  buildEventSchema,
7
+ buildFilmSchema,
6
8
  buildIdentitySchema,
7
9
  buildPodcastEpisodeSchema,
8
10
  buildPodcastSeriesSchema,
11
+ buildProductSchema,
12
+ buildServiceSchema,
9
13
  buildWebSiteSchema,
14
+ seoBrandName,
15
+ withSeoBrand,
10
16
  type EventSchemaSource,
11
17
  } from "../structuredData";
12
18
 
@@ -245,3 +251,33 @@ describe("site schemas", () => {
245
251
  expect(buildBreadcrumbSchema([{ name: "Clay", url: "https://clay.com/" }])).toBeNull();
246
252
  });
247
253
  });
254
+
255
+ describe("the business name in structured data", () => {
256
+ it("is the SEO identity's name, then the SEO site name, then the profile's", () => {
257
+ const seo = { identity: { name: " Clay Studio Ltd " }, siteName: "Clay Studio" };
258
+ expect(seoBrandName(seo, "Ada Potter")).toBe("Clay Studio Ltd");
259
+ expect(seoBrandName({ identity: null, siteName: "Clay Studio" }, "Ada Potter")).toBe("Clay Studio");
260
+ expect(seoBrandName({ identity: { name: " " }, siteName: "" }, "Ada Potter")).toBe("Ada Potter");
261
+ expect(seoBrandName(null, "Ada Potter")).toBe("Ada Potter");
262
+ expect(seoBrandName(null, null)).toBeUndefined();
263
+ });
264
+
265
+ it("names Product brand and Course, Service and Film provider the same way", () => {
266
+ const context = withSeoBrand({ currency: "GBP", brand: "Ada Potter" }, { identity: { name: "Clay Studio Ltd" }, siteName: "Clay Studio" });
267
+ expect(context).toEqual({ currency: "GBP", brand: "Clay Studio Ltd" });
268
+
269
+ const product = buildProductSchema({ title: "Mug", variants: [] } as never, context);
270
+ expect(product?.brand).toEqual({ "@type": "Organization", name: "Clay Studio Ltd" });
271
+ const course = buildCourseSchema({ title: "Wheel basics" } as never, context);
272
+ expect(course?.provider).toEqual({ "@type": "Organization", name: "Clay Studio Ltd" });
273
+ const service = buildServiceSchema({ title: "Studio hour" } as never, context);
274
+ expect(service?.provider).toEqual({ "@type": "Organization", name: "Clay Studio Ltd" });
275
+ const film = buildFilmSchema({ title: "Throwing", kind: "film" } as never, context);
276
+ expect(film?.provider).toEqual({ "@type": "Organization", name: "Clay Studio Ltd" });
277
+ });
278
+
279
+ it("keeps the profile name when there is no SEO record", () => {
280
+ expect(withSeoBrand({ currency: "GBP", brand: "Ada Potter" }, null)).toEqual({ currency: "GBP", brand: "Ada Potter" });
281
+ expect(withSeoBrand(null, null)).toEqual({ brand: undefined });
282
+ });
283
+ });
@@ -299,6 +299,30 @@ export function seoContextFromSiteConfig(
299
299
  return { currency: config.currency || undefined, brand: config.siteName || undefined };
300
300
  }
301
301
 
302
+ /**
303
+ * The name structured data gives the business behind a page: the SEO
304
+ * identity's name when the creator set one, else the SEO site name, else
305
+ * `fallback` (the site config's name, which is the profile's).
306
+ *
307
+ * One rule for every node that names the business (Product `brand`, Course,
308
+ * Service and Film `provider`, Event `organizer`), so a product page and an
309
+ * event page never disagree about who sells them.
310
+ */
311
+ export function seoBrandName(
312
+ seo?: { identity?: { name?: string | null } | null; siteName?: string | null } | null,
313
+ fallback?: string | null,
314
+ ): string | undefined {
315
+ return seo?.identity?.name?.trim() || seo?.siteName?.trim() || fallback?.trim() || undefined;
316
+ }
317
+
318
+ /** A route's `SiteSeoContext` with its `brand` named by the SEO record (see `seoBrandName`). */
319
+ export function withSeoBrand(
320
+ context: SiteSeoContext | null | undefined,
321
+ seo?: { identity?: { name?: string | null } | null; siteName?: string | null } | null,
322
+ ): SiteSeoContext {
323
+ return { ...context, brand: seoBrandName(seo, context?.brand) };
324
+ }
325
+
302
326
  /**
303
327
  * The offer for a product, derived from its variants.
304
328
  *
@@ -411,6 +435,9 @@ export interface ServiceSchemaSource {
411
435
  description?: string | null;
412
436
  media?: { url: string; type?: string }[] | null;
413
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;
414
441
  reviewAggregate?: ReviewSchemaAggregate | null;
415
442
  }
416
443
 
@@ -420,7 +447,7 @@ export function buildServiceSchema(
420
447
  options: SellableSchemaOptions = {},
421
448
  ): Record<string, unknown> | null {
422
449
  if (!service?.title) return null;
423
- const price = toPrice(service.price);
450
+ const price = toPrice(service.pricingMode === "package" ? service.packagePrice : service.price);
424
451
  return buildEntitySchema({
425
452
  type: "Service",
426
453
  name: service.title,