@tribe-nest/forge 3.21.0 → 3.23.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 (97) hide show
  1. package/package.json +2 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
  3. package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
  4. package/src/data/queries/useAuthActions.ts +1 -1
  5. package/src/data/queries/useCheckouts.ts +5 -0
  6. package/src/data/queries/useEventWaitlist.ts +123 -5
  7. package/src/data/queries/useEvents.ts +82 -4
  8. package/src/data/queries/useMembership.ts +8 -2
  9. package/src/data/queries/useMyBookings.ts +37 -0
  10. package/src/data/queries/useMyTickets.ts +112 -0
  11. package/src/data/queries/useOrders.ts +10 -0
  12. package/src/data/queries/usePassTransfers.ts +73 -13
  13. package/src/data/queries/usePaymentFlow.ts +84 -18
  14. package/src/data/queries/useShipping.ts +5 -0
  15. package/src/data/queries/useSubscriptions.ts +50 -4
  16. package/src/index.ts +5 -0
  17. package/src/server/index.ts +52 -0
  18. package/src/server/platform.ts +1 -1
  19. package/src/server/platformEvents.generated.ts +33 -33
  20. package/src/server/pwa.ts +2 -2
  21. package/src/types/models.ts +203 -5
  22. package/src/types/paystack-inline.d.ts +45 -0
  23. package/src/ui/format/_tests/attendees.spec.ts +231 -0
  24. package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
  25. package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
  26. package/src/ui/format/attendees.ts +187 -0
  27. package/src/ui/format/membershipGate.ts +209 -0
  28. package/src/ui/format/ticketAvailability.ts +226 -0
  29. package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
  30. package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
  31. package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
  32. package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
  33. package/src/ui/headless/checkout/inventoryHold.ts +83 -0
  34. package/src/ui/headless/checkout/useCheckout.ts +144 -8
  35. package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
  36. package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
  37. package/src/ui/headless/course/useCourseCheckout.ts +4 -0
  38. package/src/ui/headless/donation/Donation.tsx +10 -0
  39. package/src/ui/headless/donation/DonationContext.tsx +27 -1
  40. package/src/ui/headless/event/useEventCheckout.ts +203 -2
  41. package/src/ui/headless/event/usePresaleCode.ts +181 -0
  42. package/src/ui/headless/index.ts +25 -0
  43. package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
  44. package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
  45. package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
  46. package/src/ui/headless/offer/Offer.tsx +19 -1
  47. package/src/ui/headless/offer/OfferContext.tsx +69 -1
  48. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
  49. package/src/ui/headless/work/useWorkPortal.ts +3 -3
  50. package/src/ui/index.ts +64 -0
  51. package/src/ui/shell/diagnosticsGating.ts +3 -3
  52. package/src/ui/styled/AccountDashboard.tsx +143 -12
  53. package/src/ui/styled/AddToCalendar.tsx +34 -10
  54. package/src/ui/styled/AudioPlayer.tsx +1 -1
  55. package/src/ui/styled/BundleConfirmation.tsx +1 -1
  56. package/src/ui/styled/Checkout.tsx +42 -32
  57. package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
  58. package/src/ui/styled/CoachingBooking.tsx +11 -2
  59. package/src/ui/styled/CoachingConfirmation.tsx +7 -3
  60. package/src/ui/styled/CohortPage.tsx +1 -1
  61. package/src/ui/styled/ContactForm.tsx +1 -1
  62. package/src/ui/styled/CourseCheckout.tsx +10 -1
  63. package/src/ui/styled/CourseConfirmation.tsx +2 -2
  64. package/src/ui/styled/CourseDetail.tsx +30 -1
  65. package/src/ui/styled/DiscountCode.tsx +1 -1
  66. package/src/ui/styled/EmailListForm.tsx +1 -1
  67. package/src/ui/styled/EventConfirmation.tsx +3 -1
  68. package/src/ui/styled/EventDetail.tsx +53 -22
  69. package/src/ui/styled/EventSeriesDetail.tsx +1 -1
  70. package/src/ui/styled/EventTickets.tsx +284 -19
  71. package/src/ui/styled/EventWaitlist.tsx +23 -3
  72. package/src/ui/styled/HoldNotice.tsx +192 -0
  73. package/src/ui/styled/InvoicePayment.tsx +14 -5
  74. package/src/ui/styled/MembershipCheckout.tsx +20 -11
  75. package/src/ui/styled/MembershipGateNotice.tsx +159 -0
  76. package/src/ui/styled/OfferButton.tsx +24 -1
  77. package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
  78. package/src/ui/styled/PaystackPayButton.tsx +66 -0
  79. package/src/ui/styled/PresaleCode.tsx +174 -0
  80. package/src/ui/styled/ProductDetail.tsx +75 -5
  81. package/src/ui/styled/ProductGrid.tsx +26 -0
  82. package/src/ui/styled/ReviewForm.tsx +2 -2
  83. package/src/ui/styled/TicketTransfer.tsx +71 -42
  84. package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
  85. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
  86. package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
  87. package/src/ui/styled/community/CommunityFeed.tsx +1 -1
  88. package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
  89. package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
  90. package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
  91. package/src/utils/_tests/presaleCode.spec.ts +168 -0
  92. package/src/utils/_tests/structuredData.spec.ts +275 -0
  93. package/src/utils/membershipAccess.ts +3 -3
  94. package/src/utils/paystackCheckout.ts +277 -0
  95. package/src/utils/presaleCode.ts +96 -0
  96. package/src/utils/structuredData.ts +361 -27
  97. package/src/utils/ticketOrderOutcome.ts +1 -1
@@ -0,0 +1,283 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { ITicket } from "../../../types/models";
3
+ import {
4
+ formatOnSaleAt,
5
+ isTicketScheduled,
6
+ maxAddableTickets,
7
+ onSaleBadge,
8
+ onSaleNotice,
9
+ ticketSaleWindow,
10
+ ticketSeatLabel,
11
+ ticketSeatNotice,
12
+ ticketSeatState,
13
+ ticketSeatsAvailable,
14
+ ticketSeatsUnsold,
15
+ } from "../ticketAvailability";
16
+
17
+ /**
18
+ * The availability arithmetic shared by BOTH storefront stacks (Forge and the
19
+ * legacy `apps/client`). It is shared rather than written twice precisely so the
20
+ * two cannot drift from each other or from the server — these tests pin the
21
+ * behaviour they both depend on.
22
+ *
23
+ * The two numbers that must match the server exactly:
24
+ *
25
+ * - `quantity - sold - held`, the predicate in `inventoryHold.ts`, and
26
+ * - `now < availableFrom`, the `not_yet_on_sale` branch of
27
+ * `evaluateTierSellability`.
28
+ *
29
+ * Every case below is written as the buyer's screen versus the server's answer,
30
+ * because a disagreement between them is the whole defect class.
31
+ */
32
+
33
+ const tier = (over: Partial<ITicket> = {}): ITicket => ({
34
+ id: over.id ?? "t1",
35
+ title: "GA",
36
+ description: "",
37
+ price: 20,
38
+ quantity: 100,
39
+ order: 0,
40
+ sold: 0,
41
+ maxPerPerson: 10,
42
+ ...over,
43
+ });
44
+
45
+ /** The predicate `services/_core/inventoryHold.ts` actually runs. */
46
+ const serverWouldSell = (t: ITicket, n: number) => t.quantity - t.sold - (t.held ?? 0) >= n;
47
+
48
+ describe("ticketSeatsAvailable", () => {
49
+ it("subtracts held, which is what the server sells on", () => {
50
+ expect(ticketSeatsAvailable(tier({ quantity: 10, sold: 5, held: 3 }))).toBe(2);
51
+ });
52
+
53
+ it("treats an ABSENT held as zero, so an older API build is unchanged", () => {
54
+ // The column is published by `PUBLIC_TICKET_COLUMNS`, but a site pinned to
55
+ // a build that predates the hold engine must keep its old behaviour rather
56
+ // than read every tier as sold out.
57
+ expect(ticketSeatsAvailable(tier({ quantity: 10, sold: 5 }))).toBe(5);
58
+ });
59
+
60
+ it("never goes negative, so an oversold tier reads as zero not as a negative cap", () => {
61
+ expect(ticketSeatsAvailable(tier({ quantity: 10, sold: 9, held: 4 }))).toBe(0);
62
+ });
63
+
64
+ it("is exactly the server's predicate — the bug, stated as an assertion", () => {
65
+ // The reported case: the storefront said "3 left", the server said no.
66
+ const rush = tier({ quantity: 10, sold: 7, held: 3 });
67
+ expect(rush.quantity - rush.sold).toBe(3); // what the old UI showed
68
+ expect(ticketSeatsAvailable(rush)).toBe(0); // what the server means
69
+ expect(serverWouldSell(rush, 1)).toBe(false);
70
+ });
71
+
72
+ it("agrees with the server at every quantity across the boundary", () => {
73
+ const t = tier({ quantity: 10, sold: 6, held: 2 });
74
+ for (let n = 1; n <= 5; n += 1) {
75
+ expect(n <= ticketSeatsAvailable(t)).toBe(serverWouldSell(t, n));
76
+ }
77
+ });
78
+ });
79
+
80
+ describe("ticketSeatsUnsold", () => {
81
+ it("ignores held — it is the waitlist's question, not the buy button's", () => {
82
+ expect(ticketSeatsUnsold(tier({ quantity: 10, sold: 7, held: 3 }))).toBe(3);
83
+ });
84
+ });
85
+
86
+ describe("ticketSeatState", () => {
87
+ it("is available while a seat is genuinely free", () => {
88
+ expect(ticketSeatState(tier({ quantity: 10, sold: 5, held: 3 }))).toBe("available");
89
+ });
90
+
91
+ it("separates seats in other carts from seats that are gone", () => {
92
+ // Both are un-addable. They are NOT the same sentence: one comes back.
93
+ expect(ticketSeatState(tier({ quantity: 10, sold: 7, held: 3 }))).toBe("reserved_elsewhere");
94
+ expect(ticketSeatState(tier({ quantity: 10, sold: 10, held: 0 }))).toBe("sold_out");
95
+ });
96
+
97
+ it("calls a fully-sold tier sold out even when a hold is also outstanding", () => {
98
+ // Nothing left to release, so there is no "check back in a few minutes".
99
+ expect(ticketSeatState(tier({ quantity: 10, sold: 10, held: 2 }))).toBe("sold_out");
100
+ });
101
+ });
102
+
103
+ describe("ticketSeatLabel / ticketSeatNotice", () => {
104
+ it("gives the stepper a short word only when the tier cannot be added", () => {
105
+ expect(ticketSeatLabel("available")).toBeNull();
106
+ expect(ticketSeatLabel("reserved_elsewhere")).toBe("ON HOLD");
107
+ expect(ticketSeatLabel("sold_out")).toBe("SOLD OUT");
108
+ });
109
+
110
+ it("explains only the state a fan should wait out", () => {
111
+ expect(ticketSeatNotice("available")).toBeNull();
112
+ // A sold-out tier gets the waitlist, not a "check back" — that is what
113
+ // `isTicketSoldOut` in useEventWaitlist draws, and two prompts would fight.
114
+ expect(ticketSeatNotice("sold_out")).toBeNull();
115
+ expect(ticketSeatNotice("reserved_elsewhere")).toMatch(/someone else's checkout/);
116
+ });
117
+ });
118
+
119
+ describe("ticketSaleWindow", () => {
120
+ const now = new Date("2026-08-05T12:00:00.000Z");
121
+
122
+ it("is open when no on-sale time is set — every tier that predates the field", () => {
123
+ expect(ticketSaleWindow(tier(), now)).toEqual({ status: "open" });
124
+ expect(ticketSaleWindow(tier({ availableFrom: null }), now)).toEqual({ status: "open" });
125
+ });
126
+
127
+ it("is scheduled while the on-sale time is in the future", () => {
128
+ const w = ticketSaleWindow(tier({ availableFrom: "2026-08-07T09:00:00.000Z" }), now);
129
+ expect(w.status).toBe("scheduled");
130
+ expect(w.status === "scheduled" && w.startsAt.toISOString()).toBe("2026-08-07T09:00:00.000Z");
131
+ });
132
+
133
+ it("opens ON the boundary second, matching the server's strict `now < availableFrom`", () => {
134
+ // A storefront that allowed the boundary would produce the one refusal this
135
+ // module exists to prevent; one that refused it would hold the door shut a
136
+ // second past the announced time in a rush. Both matter.
137
+ const at = "2026-08-05T12:00:00.000Z";
138
+ expect(ticketSaleWindow(tier({ availableFrom: at }), now)).toEqual({ status: "open" });
139
+ expect(ticketSaleWindow(tier({ availableFrom: at }), new Date(Date.parse(at) - 1)).status).toBe(
140
+ "scheduled",
141
+ );
142
+ });
143
+
144
+ it("falls open on an unparseable value rather than locking the tier forever", () => {
145
+ // Failing closed here would make a bad string an unsellable tier with no
146
+ // explanation on screen. The server is the guard either way.
147
+ expect(ticketSaleWindow(tier({ availableFrom: "not a date" }), now)).toEqual({ status: "open" });
148
+ });
149
+
150
+ it("isTicketScheduled is the same verdict as a boolean", () => {
151
+ expect(isTicketScheduled(tier({ availableFrom: "2026-08-07T09:00:00.000Z" }), now)).toBe(true);
152
+ expect(isTicketScheduled(tier({ availableFrom: "2026-08-01T09:00:00.000Z" }), now)).toBe(false);
153
+ expect(isTicketScheduled(tier(), now)).toBe(false);
154
+ });
155
+ });
156
+
157
+ describe("on-sale copy", () => {
158
+ const startsAt = new Date("2026-08-07T09:00:00.000Z");
159
+
160
+ it("counts down under the hour, where the clock beats the calendar", () => {
161
+ const now = new Date(startsAt.getTime() - 12 * 60_000);
162
+ expect(onSaleBadge(startsAt, now)).toBe("On sale in 12 minutes");
163
+ });
164
+
165
+ it("does not say '1 minutes'", () => {
166
+ expect(onSaleBadge(startsAt, new Date(startsAt.getTime() - 60_000))).toBe("On sale in 1 minute");
167
+ });
168
+
169
+ it("rounds a part-minute UP, so it never announces zero while still shut", () => {
170
+ // `Math.floor` here would render "On sale in 0 minutes" beside a disabled
171
+ // button for a whole minute.
172
+ expect(onSaleBadge(startsAt, new Date(startsAt.getTime() - 30_000))).toBe("On sale in 1 minute");
173
+ });
174
+
175
+ it("switches to the absolute time at an hour and above", () => {
176
+ const now = new Date(startsAt.getTime() - 61 * 60_000);
177
+ expect(onSaleBadge(startsAt, now)).toBe(`On sale ${formatOnSaleAt(startsAt)}`);
178
+ });
179
+
180
+ it("says 'on sale now' once the instant has passed, never a negative countdown", () => {
181
+ expect(onSaleBadge(startsAt, new Date(startsAt.getTime() + 1))).toBe("On sale now");
182
+ });
183
+
184
+ it("spells the exact time out in the notice", () => {
185
+ expect(onSaleNotice(startsAt)).toBe(
186
+ `Tickets for this tier go on sale ${formatOnSaleAt(startsAt)}.`,
187
+ );
188
+ });
189
+ });
190
+
191
+ describe("maxAddableTickets", () => {
192
+ const now = new Date("2026-08-05T12:00:00.000Z");
193
+
194
+ it("is bounded by the seats the server would actually sell", () => {
195
+ expect(maxAddableTickets(tier({ quantity: 10, sold: 5, held: 3, maxPerPerson: 10 }), now)).toBe(2);
196
+ });
197
+
198
+ it("is bounded by maxPerPerson when that bites first", () => {
199
+ expect(maxAddableTickets(tier({ quantity: 100, sold: 0, held: 0, maxPerPerson: 4 }), now)).toBe(4);
200
+ });
201
+
202
+ it("is zero before a scheduled on-sale, even on a tier with the whole house free", () => {
203
+ // This is the (b) defect: 100 seats, nothing sold, and the server refuses
204
+ // every one of them with `ticket_not_yet_on_sale`.
205
+ const scheduled = tier({ quantity: 100, sold: 0, availableFrom: "2026-08-07T09:00:00.000Z" });
206
+ expect(maxAddableTickets(scheduled, now)).toBe(0);
207
+ });
208
+
209
+ it("opens to the normal cap the moment the on-sale time arrives", () => {
210
+ const at = "2026-08-05T12:00:00.000Z";
211
+ const t = tier({ quantity: 100, sold: 0, maxPerPerson: 4, availableFrom: at });
212
+ expect(maxAddableTickets(t, new Date(Date.parse(at) - 1))).toBe(0);
213
+ expect(maxAddableTickets(t, new Date(at))).toBe(4);
214
+ });
215
+
216
+ it("is zero for a tier whose remaining seats are all in other carts", () => {
217
+ expect(maxAddableTickets(tier({ quantity: 10, sold: 7, held: 3 }), now)).toBe(0);
218
+ });
219
+
220
+ it("never exceeds what the server will accept, for any tier shape", () => {
221
+ for (const quantity of [0, 1, 10]) {
222
+ for (const sold of [0, 1, 9]) {
223
+ for (const held of [0, 1, 5]) {
224
+ for (const maxPerPerson of [1, 4, 100]) {
225
+ const t = tier({ quantity, sold, held, maxPerPerson });
226
+ const cap = maxAddableTickets(t, now);
227
+ if (cap > 0) expect(serverWouldSell(t, cap)).toBe(true);
228
+ }
229
+ }
230
+ }
231
+ }
232
+ });
233
+ });
234
+
235
+ /**
236
+ * Events 2.6b — the offeree's OWN reservation.
237
+ *
238
+ * A waitlist offer takes a real hold, which is what stops a stranger taking the
239
+ * seat. The side effect is that the tier reads sold out to the person it is held
240
+ * for, so the picker has to know which of `held` is theirs — otherwise the claim
241
+ * link opens a page with the `+` disabled on the seat they were promised.
242
+ */
243
+ describe("reservedForYou", () => {
244
+ const tier = { quantity: 10, sold: 8, held: 2, maxPerPerson: null, availableFrom: null };
245
+
246
+ it("changes nothing at all when omitted — every existing caller is untouched", () => {
247
+ expect(ticketSeatsAvailable(tier)).toBe(0);
248
+ expect(ticketSeatState(tier)).toBe("reserved_elsewhere");
249
+ expect(maxAddableTickets(tier)).toBe(0);
250
+ });
251
+
252
+ it("re-opens exactly the seats held for this visitor, and no more", () => {
253
+ expect(ticketSeatsAvailable(tier, 2)).toBe(2);
254
+ expect(ticketSeatState(tier, 2)).toBe("available");
255
+ expect(maxAddableTickets(tier, new Date(), 2)).toBe(2);
256
+ // Somebody else's seat is still somebody else's.
257
+ expect(ticketSeatsAvailable(tier, 1)).toBe(1);
258
+ });
259
+
260
+ it("never lets a claimed reservation exceed the artist's per-person cap", () => {
261
+ // The offer says how many seats are held; `maxPerPerson` says how many one
262
+ // person may hold at all, and an offer is not a licence to exceed it. The
263
+ // server applies the same cap, so exempting it here would only move the
264
+ // refusal to the card screen.
265
+ expect(maxAddableTickets({ ...tier, maxPerPerson: 1 }, new Date(), 2)).toBe(1);
266
+ });
267
+
268
+ it("cannot conjure seats out of a genuinely sold-out tier", () => {
269
+ const soldOut = { quantity: 5, sold: 5, held: 0, maxPerPerson: null, availableFrom: null };
270
+ // Nothing is held here, so nothing can be held FOR anybody. A tampered value
271
+ // buys nothing: the server re-derives the number from the hold it issued.
272
+ expect(ticketSeatsAvailable(soldOut, 3)).toBe(3);
273
+ // …which is why the guard that matters is the claim itself, not this
274
+ // arithmetic. What this pins is that a NEGATIVE or absent value is inert.
275
+ expect(ticketSeatsAvailable(soldOut, -3)).toBe(0);
276
+ expect(maxAddableTickets(soldOut, new Date(), -3)).toBe(0);
277
+ });
278
+
279
+ it("still refuses a tier whose on-sale time has not arrived", () => {
280
+ const scheduled = { ...tier, availableFrom: new Date(Date.now() + 86_400_000).toISOString() };
281
+ expect(maxAddableTickets(scheduled, new Date(), 2)).toBe(0);
282
+ });
283
+ });
@@ -0,0 +1,187 @@
1
+ import type { IEvent, ITicket } from "../../types/models";
2
+
3
+ /**
4
+ * Events 2.1 — asking the buyer WHO IS COMING, on both rendering stacks.
5
+ *
6
+ * The operator toggle ("Ask for each attendee's name at checkout") and the
7
+ * server that accepts the answer both shipped; nothing ever asked. Every pass
8
+ * of a four-ticket order was therefore issued in the purchaser's name, and the
9
+ * setting looked broken because it was silent.
10
+ *
11
+ * ## Why the logic is here and not in a component
12
+ *
13
+ * There are two storefronts — `apps/client` (the PWA) and `packages/forge`'s
14
+ * own `<EventTickets>` (code sites) — and each renders its own checkout. The
15
+ * one thing they must NOT each derive for themselves is the shape of the
16
+ * request: `attendees` is keyed by ticket id and positional WITHIN a tier, and
17
+ * a list whose length does not equal that tier's quantity is refused outright
18
+ * by `validateAttendeeList` on the server. So slot construction, the buyer
19
+ * default, validation and the payload all live in these pure functions, and
20
+ * both stacks only decide what the inputs look like.
21
+ *
22
+ * ## Names only — deliberately
23
+ *
24
+ * The endpoint also accepts a per-attendee `email`, and that field is not a
25
+ * contact detail: `owner_email` is WHO HOLDS the pass, so naming a different
26
+ * address hands that seat to that person, who may then rename or transfer it
27
+ * instead of the buyer (`db/types/attendee.ts`, `commands/passTransfer.ts`).
28
+ * The operator toggle asks for a name and says so. Sending only names keeps
29
+ * every pass in the buyer's account — which is exactly the branch the backend
30
+ * documents as "what a storefront that only asks for names produces" — and
31
+ * leaves handing a ticket to somebody else to the transfer flow, which has a
32
+ * claim token, an expiry and an audit trail.
33
+ *
34
+ * ## A blank must never pass
35
+ *
36
+ * A missing name here is a person turned away at a door. The server's per-slot
37
+ * fallback would quietly put the BUYER's name on an unfilled seat rather than
38
+ * error, so a half-filled form would produce a wrong door list with a 200.
39
+ * `validateAttendeeNames` is what stops that before the request is made.
40
+ */
41
+
42
+ /** Matches `validation.event.attendee_name.max` and the server's own cap. */
43
+ export const MAX_ATTENDEE_NAME_LENGTH = 240;
44
+
45
+ /**
46
+ * Trim and collapse internal whitespace — the same normalisation the server
47
+ * applies (`normalizeAttendeeName`), so " Jane Doe " and "Jane Doe" cannot
48
+ * sort into two places on a printed door list.
49
+ */
50
+ export const normalizeAttendeeName = (raw?: string | null): string => (raw ?? "").replace(/\s+/g, " ").trim();
51
+
52
+ /** Does this event ask for attendee names at all? Absent field means no. */
53
+ export const collectsAttendees = (event?: Pick<IEvent, "collectAttendeeDetails"> | null): boolean =>
54
+ !!event?.collectAttendeeDetails;
55
+
56
+ /** The buyer's own name, as the first seat's default. Empty until they type it. */
57
+ export const buyerFullName = (input: { firstName?: string | null; lastName?: string | null }): string =>
58
+ normalizeAttendeeName(`${input.firstName ?? ""} ${input.lastName ?? ""}`);
59
+
60
+ /** ticketId → the names typed so far, positional within that tier. */
61
+ export type AttendeeNames = Record<string, (string | undefined)[]>;
62
+
63
+ /** One seat the buyer has to name. */
64
+ export type AttendeeSlot = {
65
+ ticketId: string;
66
+ ticketTitle: string;
67
+ /** 0-based position WITHIN this tier — the index the request is keyed on. */
68
+ index: number;
69
+ /** 1-based position within this tier, for a label. */
70
+ number: number;
71
+ /** How many seats of this tier are in the cart. */
72
+ ticketQuantity: number;
73
+ /**
74
+ * The very first seat in the cart. It defaults to the buyer, so the common
75
+ * case — one ticket, bought for yourself — asks for nothing already known.
76
+ */
77
+ isBuyer: boolean;
78
+ /** What the input should show: what they typed, else the buyer default. */
79
+ value: string;
80
+ /** "General Admission · Attendee 2 of 3", or just the tier on a single seat. */
81
+ label: string;
82
+ };
83
+
84
+ /**
85
+ * Every seat in the cart, in tier order then issue order.
86
+ *
87
+ * Derived from the CURRENT quantities on every call rather than stored, so a
88
+ * buyer who drops from four tickets to two cannot leave two orphan names behind
89
+ * (which the server would reject as a count mismatch) and one who goes back up
90
+ * to four gets what they already typed back.
91
+ *
92
+ * `undefined` and `""` are different on purpose: an untouched first seat shows
93
+ * the buyer's name, a first seat the buyer CLEARED stays empty and fails
94
+ * validation. Snapping it back would silently overrule a deliberate edit.
95
+ */
96
+ export function buildAttendeeSlots(input: {
97
+ tickets: Pick<ITicket, "id" | "title">[];
98
+ quantities: Record<string, number>;
99
+ names: AttendeeNames;
100
+ /** Pre-fills the first seat only. Pass "" when the buyer has not typed a name yet. */
101
+ buyerName: string;
102
+ }): AttendeeSlot[] {
103
+ const slots: AttendeeSlot[] = [];
104
+
105
+ for (const ticket of input.tickets) {
106
+ const quantity = Math.max(0, Math.floor(input.quantities[ticket.id] ?? 0));
107
+ for (let index = 0; index < quantity; index += 1) {
108
+ const isBuyer = slots.length === 0;
109
+ const typed = input.names[ticket.id]?.[index];
110
+ slots.push({
111
+ ticketId: ticket.id,
112
+ ticketTitle: ticket.title,
113
+ index,
114
+ number: index + 1,
115
+ ticketQuantity: quantity,
116
+ isBuyer,
117
+ value: typed !== undefined ? typed : isBuyer ? input.buyerName : "",
118
+ label: quantity > 1 ? `${ticket.title} · Attendee ${index + 1} of ${quantity}` : ticket.title,
119
+ });
120
+ }
121
+ }
122
+
123
+ return slots;
124
+ }
125
+
126
+ /** Write one seat's name back into the positional map. */
127
+ export function setAttendeeName(names: AttendeeNames, ticketId: string, index: number, value: string): AttendeeNames {
128
+ const existing = names[ticketId] ?? [];
129
+ const next = existing.slice();
130
+ // A sparse array would serialise its holes as `null`; fill so every earlier
131
+ // seat stays `undefined` (= "not touched") rather than becoming a value.
132
+ while (next.length < index) next.push(undefined);
133
+ next[index] = value;
134
+ return { ...names, [ticketId]: next };
135
+ }
136
+
137
+ export type AttendeeProblem = "name_required" | "name_too_long";
138
+
139
+ export type AttendeeCheck = { ok: true } | { ok: false; reason: AttendeeProblem; slot: AttendeeSlot; message: string };
140
+
141
+ /**
142
+ * Every seat named, and no name over the cap.
143
+ *
144
+ * Returns the OFFENDING SLOT, not just a boolean, so a storefront can point at
145
+ * the field rather than say "something is missing" above a list of eight
146
+ * inputs.
147
+ */
148
+ export function validateAttendeeNames(slots: AttendeeSlot[]): AttendeeCheck {
149
+ for (const slot of slots) {
150
+ const name = normalizeAttendeeName(slot.value);
151
+ if (name.length === 0) {
152
+ return {
153
+ ok: false,
154
+ reason: "name_required",
155
+ slot,
156
+ message: `Please enter a name for ${slot.label}.`,
157
+ };
158
+ }
159
+ if (name.length > MAX_ATTENDEE_NAME_LENGTH) {
160
+ return {
161
+ ok: false,
162
+ reason: "name_too_long",
163
+ slot,
164
+ message: `The name for ${slot.label} is too long (max ${MAX_ATTENDEE_NAME_LENGTH} characters).`,
165
+ };
166
+ }
167
+ }
168
+ return { ok: true };
169
+ }
170
+
171
+ /**
172
+ * The `attendees` map for `POST /public/events/:id/orders`.
173
+ *
174
+ * Built from the SLOTS, which is what guarantees exactly `quantity` entries per
175
+ * tier — the one shape the server refuses. Returns `undefined` when there is
176
+ * nothing to send, so an event that does not collect posts the body it always
177
+ * did (and the server does not read the field on that branch anyway).
178
+ */
179
+ export function attendeesPayload(slots: AttendeeSlot[]): Record<string, { name: string }[]> | undefined {
180
+ if (slots.length === 0) return undefined;
181
+ const out: Record<string, { name: string }[]> = {};
182
+ for (const slot of slots) {
183
+ const list = (out[slot.ticketId] ??= []);
184
+ list[slot.index] = { name: normalizeAttendeeName(slot.value) };
185
+ }
186
+ return out;
187
+ }
@@ -0,0 +1,209 @@
1
+ import { safeRedirectPath } from "../../utils/safeRedirect";
2
+ import type { MembershipGateReason, PublicMembershipGate } from "../../types/models";
3
+
4
+ /**
5
+ * Members-only things, as a buyer has to see them (S.4, (c)).
6
+ *
7
+ * ## The defect this module exists to close
8
+ *
9
+ * The gate shipped ENFORCED and UNANNOUNCED. A logged-out buyer saw an ordinary
10
+ * ticket, chose a seat, typed their name and their email twice, and was refused
11
+ * on the last screen with a `MEMBERSHIP_GATE` 400 — the restriction was
12
+ * discoverable only by failing it. And the "members save X, sign in" funnel the
13
+ * feature was justified by existed on no surface at all, because nothing drew a
14
+ * way OUT of the refusal.
15
+ *
16
+ * ## Why it is pure, and why it lives in `format/`
17
+ *
18
+ * There are two rendering stacks — `apps/client` and the Forge SDK that code
19
+ * websites build against — and a members-only badge that says one thing on an
20
+ * artist's PWA and another on their website is the same bug twice. So the words
21
+ * and the resolving ACTION are decided once, here, with no React and no client;
22
+ * each stack owns only how it looks. `apps/client` imports this from
23
+ * `@tribe-nest/forge/ui`, exactly as it already does for the PWYW arithmetic.
24
+ *
25
+ * Pure and total also means the whole mapping is testable without a DOM or a
26
+ * session — and getting the two reasons backwards is the failure mode that
27
+ * matters most: telling someone who is already signed in to "sign in" is a loop
28
+ * with no exit, and telling a logged-out visitor to "upgrade" asks them to buy a
29
+ * membership they may already hold.
30
+ */
31
+
32
+ // The wire shapes live in `types/models` beside the payloads that carry them;
33
+ // re-exported here so a site importing the renderer gets the type with it.
34
+ export type { MembershipGateReason, PublicMembershipGate };
35
+
36
+ /** The API's stable machine-readable refusal code. Switch on this, never on prose. */
37
+ export const MEMBERSHIP_GATE_CODE = "MEMBERSHIP_GATE";
38
+
39
+ /** A `MEMBERSHIP_GATE` response, unpacked. */
40
+ export type MembershipGateRefusal = {
41
+ reason: MembershipGateReason;
42
+ requiredTierIds: string[];
43
+ /** `event`, `event_ticket`, `course`, `product`, `post`. */
44
+ targetType: string;
45
+ targetId: string;
46
+ /** The API's own sentence, when it sent one. */
47
+ message: string | null;
48
+ };
49
+
50
+ type ApiErrorShape = {
51
+ response?: {
52
+ data?: {
53
+ code?: string;
54
+ message?: string;
55
+ details?: {
56
+ reason?: string;
57
+ requiredTierIds?: unknown;
58
+ targetType?: unknown;
59
+ targetId?: unknown;
60
+ };
61
+ };
62
+ };
63
+ };
64
+
65
+ const asStringArray = (value: unknown): string[] =>
66
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
67
+
68
+ /**
69
+ * A thrown request → the gate refusal inside it, or `null` for anything else.
70
+ *
71
+ * Matched on `code`, not on the status: a checkout refusal is 400 and a read
72
+ * refusal is 401 or 403, and a client that switched on the status would render
73
+ * the members-only prompt as a session timeout on one of them.
74
+ *
75
+ * The `reason` is validated rather than cast. An unrecognised value falls back
76
+ * to `membership_required`, which is the safe half: it offers a way to BUY
77
+ * access, whereas a wrong `sign_in_required` offers a signed-in member a sign-in
78
+ * button that changes nothing.
79
+ */
80
+ export function parseMembershipGateError(error: unknown): MembershipGateRefusal | null {
81
+ const data = (error as ApiErrorShape)?.response?.data;
82
+ if (!data || data.code !== MEMBERSHIP_GATE_CODE) return null;
83
+
84
+ const details = data.details ?? {};
85
+ return {
86
+ reason: details.reason === "sign_in_required" ? "sign_in_required" : "membership_required",
87
+ requiredTierIds: asStringArray(details.requiredTierIds),
88
+ targetType: typeof details.targetType === "string" ? details.targetType : "",
89
+ targetId: typeof details.targetId === "string" ? details.targetId : "",
90
+ message: typeof data.message === "string" && data.message.trim() ? data.message : null,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Is this thing refused to the caller looking at it?
96
+ *
97
+ * `null` means "nothing gates it" — which is also what every read returns while
98
+ * the `membership_entitlement_gates` switch is off. Treating an absent gate as
99
+ * "gated and permitted" would badge every item on every site the day the field
100
+ * shipped, so absent must read as false here and nowhere else.
101
+ */
102
+ export function isMembershipGateLocked(gate: PublicMembershipGate | null | undefined): boolean {
103
+ return !!gate && !gate.allowed;
104
+ }
105
+
106
+ /** What a surface draws in place of, or beside, a gated thing. */
107
+ export type MembershipGateNotice = {
108
+ reason: MembershipGateReason;
109
+ /** Two words for a list row, where a sentence does not fit. */
110
+ badge: string;
111
+ title: string;
112
+ body: string;
113
+ /** Primary CTA label — the way OUT of the refusal, which is the whole point. */
114
+ actionLabel: string;
115
+ /**
116
+ * Where the CTA goes. `null` only when the caller passed no path to send them
117
+ * to; a surface with a null href should render the notice without a button
118
+ * rather than a button that goes nowhere.
119
+ */
120
+ actionHref: string | null;
121
+ };
122
+
123
+ /** Default sign-in path. Overridden per site — code websites mount theirs under `/i`. */
124
+ export const MEMBERSHIP_GATE_LOGIN_PATH = "/login";
125
+ /** Default tier-listing path, i.e. where "become a member" actually leads. */
126
+ export const MEMBERSHIP_GATE_MEMBERSHIP_PATH = "/membership";
127
+
128
+ /** "Gold", "Gold or Platinum", "Gold, Silver or Bronze" — ANY one unlocks, hence "or". */
129
+ export function joinTierNames(names: string[]): string {
130
+ const clean = names.map((name) => name.trim()).filter(Boolean);
131
+ if (clean.length === 0) return "";
132
+ if (clean.length === 1) return clean[0];
133
+ return `${clean.slice(0, -1).join(", ")} or ${clean[clean.length - 1]}`;
134
+ }
135
+
136
+ /** `path?redirect=<here>` — a sign-in that lands back on the thing they wanted. */
137
+ export function membershipSignInHref(loginPath: string, currentPath: string): string {
138
+ const target = safeRedirectPath(currentPath, "");
139
+ return target ? `${loginPath}?redirect=${encodeURIComponent(target)}` : loginPath;
140
+ }
141
+
142
+ export interface BuildMembershipGateNoticeInput {
143
+ /** The announced gate from a listing/detail read. */
144
+ gate?: PublicMembershipGate | null;
145
+ /** A `MEMBERSHIP_GATE` refusal from a failed purchase. Wins over `gate` — it is the newer fact. */
146
+ refusal?: MembershipGateRefusal | null;
147
+ /**
148
+ * Names for `requiredTierIds`, already resolved by the caller (both stacks
149
+ * already fetch the public tier list). Ids are never shown: "you need tier
150
+ * 8f3c-…" is not a sentence anyone can act on.
151
+ */
152
+ tierNames?: string[];
153
+ /** What the thing IS, in the buyer's words — "This ticket", "This course". */
154
+ itemLabel?: string;
155
+ loginPath?: string;
156
+ membershipPath?: string;
157
+ /** Path (+ search) to return to after signing in. */
158
+ currentPath?: string;
159
+ }
160
+
161
+ /**
162
+ * The notice for a gated thing, or `null` when there is nothing to say.
163
+ *
164
+ * A `refusal` beats a `gate` because it is the more recent read of the same
165
+ * fact: the page may have rendered `allowed: true` from a stale cache, or the
166
+ * buyer's membership may have lapsed between the page load and the pay button,
167
+ * and the server's "no" at the moment of purchase is what actually happened.
168
+ */
169
+ export function buildMembershipGateNotice(
170
+ input: BuildMembershipGateNoticeInput,
171
+ ): MembershipGateNotice | null {
172
+ const reason = input.refusal?.reason ?? (isMembershipGateLocked(input.gate) ? input.gate!.reason : null);
173
+ if (!reason) return null;
174
+
175
+ const item = input.itemLabel?.trim() || "This";
176
+ const tiers = joinTierNames(input.tierNames ?? []);
177
+ const loginPath = input.loginPath ?? MEMBERSHIP_GATE_LOGIN_PATH;
178
+ const membershipPath = input.membershipPath ?? MEMBERSHIP_GATE_MEMBERSHIP_PATH;
179
+
180
+ if (reason === "sign_in_required") {
181
+ return {
182
+ reason,
183
+ badge: "Members only",
184
+ title: "Members only",
185
+ // The API's own sentence is deliberately NOT used for this half. It is
186
+ // written for the refusal ("You must be a member to buy this"), and what
187
+ // an anonymous visitor needs first is the possibility that they already
188
+ // are one — which is the difference between a dead end and a funnel.
189
+ body: tiers
190
+ ? `${item} is for ${tiers} members. Already one? Sign in and it unlocks.`
191
+ : `${item} is for members. Already one? Sign in and it unlocks.`,
192
+ actionLabel: "Sign in",
193
+ actionHref: membershipSignInHref(loginPath, input.currentPath ?? ""),
194
+ };
195
+ }
196
+
197
+ return {
198
+ reason,
199
+ badge: "Members only",
200
+ title: "Members only",
201
+ body: tiers
202
+ ? `${item} is for ${tiers} members. Your current plan doesn't include it.`
203
+ : `${item} is for members. Your current plan doesn't include it.`,
204
+ // NOT "sign in" — they already are, and offering it again is a loop with no
205
+ // exit. The only move that changes anything is joining the tier.
206
+ actionLabel: tiers ? `View ${tiers}` : "View membership",
207
+ actionHref: membershipPath,
208
+ };
209
+ }