@tribe-nest/forge 3.21.0 → 3.22.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 (48) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
  3. package/src/data/queries/useEvents.ts +66 -4
  4. package/src/data/queries/useMembership.ts +8 -2
  5. package/src/data/queries/useMyBookings.ts +12 -0
  6. package/src/data/queries/useMyTickets.ts +112 -0
  7. package/src/data/queries/useOrders.ts +10 -0
  8. package/src/data/queries/usePassTransfers.ts +73 -13
  9. package/src/server/index.ts +52 -0
  10. package/src/types/models.ts +151 -0
  11. package/src/ui/format/_tests/attendees.spec.ts +231 -0
  12. package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
  13. package/src/ui/format/attendees.ts +187 -0
  14. package/src/ui/format/membershipGate.ts +209 -0
  15. package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
  16. package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
  17. package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
  18. package/src/ui/headless/checkout/inventoryHold.ts +83 -0
  19. package/src/ui/headless/checkout/useCheckout.ts +72 -0
  20. package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
  21. package/src/ui/headless/event/useEventCheckout.ts +133 -2
  22. package/src/ui/headless/event/usePresaleCode.ts +181 -0
  23. package/src/ui/headless/index.ts +25 -0
  24. package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
  25. package/src/ui/headless/offer/OfferContext.tsx +55 -0
  26. package/src/ui/index.ts +42 -0
  27. package/src/ui/styled/AccountDashboard.tsx +70 -8
  28. package/src/ui/styled/AddToCalendar.tsx +34 -10
  29. package/src/ui/styled/Checkout.tsx +18 -1
  30. package/src/ui/styled/CoachingConfirmation.tsx +4 -0
  31. package/src/ui/styled/CourseDetail.tsx +30 -1
  32. package/src/ui/styled/EventConfirmation.tsx +2 -0
  33. package/src/ui/styled/EventDetail.tsx +53 -22
  34. package/src/ui/styled/EventTickets.tsx +156 -5
  35. package/src/ui/styled/HoldNotice.tsx +192 -0
  36. package/src/ui/styled/MembershipGateNotice.tsx +159 -0
  37. package/src/ui/styled/OfferButton.tsx +23 -0
  38. package/src/ui/styled/PresaleCode.tsx +174 -0
  39. package/src/ui/styled/ProductDetail.tsx +75 -5
  40. package/src/ui/styled/ProductGrid.tsx +26 -0
  41. package/src/ui/styled/TicketTransfer.tsx +69 -40
  42. package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
  43. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
  44. package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
  45. package/src/utils/_tests/presaleCode.spec.ts +168 -0
  46. package/src/utils/_tests/structuredData.spec.ts +275 -0
  47. package/src/utils/presaleCode.ts +96 -0
  48. package/src/utils/structuredData.ts +361 -27
@@ -80,6 +80,17 @@ export type PaymentStartResponse = {
80
80
  shippingCosts?: { deliveryGroupId: string; amount: number; currency: string }[];
81
81
  /** Authoritative sales-tax quote for this charge (all wired pillars). */
82
82
  taxQuote?: PublicTaxQuote;
83
+ /**
84
+ * When the reserved stock goes back on sale, ISO-8601 — the RESTARTED clock,
85
+ * measured from the card form rather than from when the cart was assembled,
86
+ * so this supersedes whatever the create call reported.
87
+ *
88
+ * `null` (or absent, on an older API) when this order holds no inventory:
89
+ * a digital/service-only cart, an already-settled free order, or a profile
90
+ * with the holds switch off. Nothing may show a countdown in that case —
91
+ * "no reservation" is not "expires now".
92
+ */
93
+ holdExpiresAt?: string | null;
83
94
  };
84
95
 
85
96
  /** Normalized result surfaced by usePaymentFlow. */
@@ -825,6 +836,11 @@ export type IPublicProduct = {
825
836
  height?: number;
826
837
  dimensionUnit?: string;
827
838
  shippingCountries?: string[] | null;
839
+ /**
840
+ * Members-only restriction, from `GET /public/products` and the detail read.
841
+ * `null`/absent when nothing gates it. See {@link PublicMembershipGate}.
842
+ */
843
+ membershipGate?: PublicMembershipGate | null;
828
844
  };
829
845
 
830
846
  export type QuestionnaireQuestion = {
@@ -885,6 +901,40 @@ export type CoachingProduct = {
885
901
  cancellation?: CancellationTermsView | null;
886
902
  };
887
903
 
904
+ // ---- Membership gates (S.4) --------------------------------------------------
905
+
906
+ /**
907
+ * `sign_in_required` — the server does not know who the caller is, so they may
908
+ * already be a member. `membership_required` — it does, and they are not.
909
+ *
910
+ * Same outcome, different next action. That is the entire reason the API
911
+ * distinguishes them instead of sending one "denied", and a renderer that
912
+ * collapses them either tells a signed-in member to sign in (a loop with no
913
+ * exit) or asks an anonymous visitor to buy a membership they may already hold.
914
+ */
915
+ export type MembershipGateReason = "membership_required" | "sign_in_required";
916
+
917
+ /**
918
+ * A members-only restriction, as announced by a PUBLIC read.
919
+ *
920
+ * Present on tickets, events, products and courses. `null` on anything ungated,
921
+ * and on everything in a workspace where the gates switch is off — so a surface
922
+ * that has never heard of this field behaves exactly as it did before.
923
+ *
924
+ * The item is ANNOUNCED, never hidden: "Gold members get first refusal on the
925
+ * vinyl" only works as an offer if non-members can see it. Which means the
926
+ * storefront owes the buyer the badge AND the way out of it — see
927
+ * `buildMembershipGateNotice` in `ui/format/membershipGate`.
928
+ */
929
+ export type PublicMembershipGate = {
930
+ /** Which tiers unlock it. ANY one is enough — tiers are alternatives, never components. */
931
+ requiredTierIds: string[];
932
+ /** True when THIS caller may buy. */
933
+ allowed: boolean;
934
+ /** `null` exactly when `allowed`. */
935
+ reason: MembershipGateReason | null;
936
+ };
937
+
888
938
  export type PublicCourseLesson = {
889
939
  id: string;
890
940
  title: string;
@@ -918,6 +968,15 @@ export type PublicCourse = {
918
968
  modules?: PublicCourseModule[];
919
969
  /** Rating aggregate — present on the detail endpoint, null until first published review. */
920
970
  reviewAggregate?: IReviewAggregate | null;
971
+ /**
972
+ * Members-only restriction — present on the course DETAIL read.
973
+ *
974
+ * A live gate locks the content even for someone holding a grant, because
975
+ * "this course is part of Gold" is a condition for access rather than a
976
+ * one-off unlock. So a lapsed member sees the outline and this gate, and the
977
+ * page owes them the way back in. See {@link PublicMembershipGate}.
978
+ */
979
+ membershipGate?: PublicMembershipGate | null;
921
980
  };
922
981
 
923
982
  // ---- Blog --------------------------------------------------------------------
@@ -1004,6 +1063,14 @@ export interface IEvent {
1004
1063
  timezone?: string;
1005
1064
  type: "physical" | "virtual" | "hybrid";
1006
1065
  ticketSaleMessage?: string;
1066
+ /**
1067
+ * Members-only restriction on the WHOLE event — no tier of it sells to a
1068
+ * non-member. Distinct from `ITicket.membershipGate`, which restricts one tier
1069
+ * alongside public ones, and the two are asserted separately at checkout: a
1070
+ * page that collapses them badges every tier on a members-only event, or none
1071
+ * on a members-only tier. Present on the detail read only.
1072
+ */
1073
+ membershipGate?: PublicMembershipGate | null;
1007
1074
  questionnaire?: {
1008
1075
  id: string;
1009
1076
  question: string;
@@ -1019,6 +1086,16 @@ export interface IEvent {
1019
1086
  country: string;
1020
1087
  zipCode?: string;
1021
1088
  };
1089
+ /**
1090
+ * The host asks for one NAME PER PASS at checkout (Events 2.1).
1091
+ *
1092
+ * Optional because every event that predates the feature comes down without
1093
+ * it, and because a renderer must treat "absent" as "do not ask" — an event
1094
+ * that has not opted in must be unaffected by the field existing at all. A
1095
+ * checkout that ignores this ships four passes in the buyer's name and the
1096
+ * artist's door list is wrong; see `ui/format/attendees.ts`.
1097
+ */
1098
+ collectAttendeeDetails?: boolean;
1022
1099
  title: string;
1023
1100
  description?: string;
1024
1101
  actionText: string;
@@ -1038,6 +1115,52 @@ export interface IEvent {
1038
1115
  * must not assume it is present.
1039
1116
  */
1040
1117
  bookingFee?: IBookingFee | null;
1118
+ /**
1119
+ * This event's `.ics` address, in both forms — the DETAIL endpoint only.
1120
+ *
1121
+ * `calendarUrl` is the https download; `calendarWebcalUrl` is the same
1122
+ * address under the scheme iOS and macOS route to Calendar. Apple publishes
1123
+ * no template URL the way Google and Microsoft do, so without these an
1124
+ * add-to-calendar control has no Apple option at all — which is exactly the
1125
+ * state every surface in this product was in until they existed.
1126
+ *
1127
+ * Not derivable client-side: the address carries a keyed HMAC. Optional
1128
+ * because `useEvents()` (the list) deliberately does not mint one, and null
1129
+ * for a cancelled show, where the feed would 404 anyway.
1130
+ */
1131
+ calendarUrl?: string | null;
1132
+ calendarWebcalUrl?: string | null;
1133
+ /**
1134
+ * Whether this event has a presale behind a code, and whether the code sent
1135
+ * with THIS request opened it (1.2).
1136
+ *
1137
+ * Present only on the DETAIL endpoint, and only there because it is the only
1138
+ * page with a box to type a code into. Absent on `useEvents()`.
1139
+ *
1140
+ * Neither field is derivable from `tickets`: a hidden tier is filtered out
1141
+ * server-side (so a presale-only event looks like an event with no tickets at
1142
+ * all), and a VISIBLE code-gated tier is already listed (so the right code
1143
+ * changes nothing on screen). A renderer that guessed would either show a code
1144
+ * box on every event or call a correct code wrong.
1145
+ */
1146
+ presale?: IEventPresaleState;
1147
+ }
1148
+
1149
+ /**
1150
+ * The two facts a storefront needs to offer a presale-code box.
1151
+ *
1152
+ * It carries nothing about the codes themselves — not how many exist, not which
1153
+ * tier a code opened. A visitor guessing learns only "not a code for this
1154
+ * event", which is what they would learn by trying to check out anyway.
1155
+ */
1156
+ export interface IEventPresaleState {
1157
+ /** At least one live tier is code-gated. FALSE means: render no box at all. */
1158
+ hasCodedTiers: boolean;
1159
+ /**
1160
+ * `null` when no code was sent — a first page load must not open with a
1161
+ * rejection. `false` is the only value that should show "not valid".
1162
+ */
1163
+ codeAccepted: boolean | null;
1041
1164
  }
1042
1165
 
1043
1166
  // ---- Event series ------------------------------------------------------------
@@ -1128,6 +1251,27 @@ export type ITicket = {
1128
1251
  maxPerPerson: number;
1129
1252
  expiresAt?: string;
1130
1253
  archivedAt?: string;
1254
+ /**
1255
+ * This tier is not listed publicly (1.2). If you are looking at one, it is
1256
+ * because a presale code revealed it — the server filters hidden tiers out of
1257
+ * every response that did not carry the right code.
1258
+ *
1259
+ * Worth badging: a buyer who has just unlocked something should be able to
1260
+ * SEE what unlocked, otherwise a correct code looks like it did nothing.
1261
+ */
1262
+ isHidden?: boolean;
1263
+ /**
1264
+ * Members-only restriction on THIS tier — the members-only ticket alongside
1265
+ * public ones. Present on the event DETAIL read; absent on list endpoints,
1266
+ * which quote no ticket and offer no buy button.
1267
+ *
1268
+ * A gated tier stays in the list on purpose: hiding it kills the upsell that
1269
+ * is the whole reason to sell a members-only ticket. So a renderer must badge
1270
+ * it and offer the resolving action — sign in, or view the tier — rather than
1271
+ * let the buyer discover the gate by being refused at payment. See
1272
+ * {@link PublicMembershipGate}.
1273
+ */
1274
+ membershipGate?: PublicMembershipGate | null;
1131
1275
  };
1132
1276
 
1133
1277
  // ---- Invoices ----------------------------------------------------------------
@@ -1423,6 +1567,13 @@ export type CoachingBooking = {
1423
1567
  productTitle?: string | null;
1424
1568
  durationMinutes?: number | null;
1425
1569
  timezone?: string | null;
1570
+ /**
1571
+ * The session's `.ics`, both forms — see {@link CalendarFeedLinks}. Null
1572
+ * unless the booking is CONFIRMED: a held slot is not a session, and offering
1573
+ * to diarise a cancelled one is worse than offering nothing.
1574
+ */
1575
+ calendarUrl?: string | null;
1576
+ calendarWebcalUrl?: string | null;
1426
1577
  };
1427
1578
 
1428
1579
  export type CourseBooking = {
@@ -0,0 +1,231 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { ITicket } from "../../../types/models";
3
+ import {
4
+ attendeesPayload,
5
+ buildAttendeeSlots,
6
+ buyerFullName,
7
+ collectsAttendees,
8
+ MAX_ATTENDEE_NAME_LENGTH,
9
+ normalizeAttendeeName,
10
+ setAttendeeName,
11
+ validateAttendeeNames,
12
+ type AttendeeNames,
13
+ } from "../attendees";
14
+
15
+ /**
16
+ * Per-attendee names at checkout, shared by BOTH storefront stacks.
17
+ *
18
+ * Two properties matter more than the rest and are pinned first:
19
+ *
20
+ * 1. An event that does not ask produces NO slots, NO validation and NO
21
+ * payload — every pre-2.1 event must post the body it always posted.
22
+ * 2. The payload always carries exactly `quantity` entries per tier. The
23
+ * server refuses any other length (`validateAttendeeList` →
24
+ * `count_mismatch`), and a short list that slipped past would leave a
25
+ * silent pass in the purchaser's name — a stranger at the door.
26
+ */
27
+
28
+ const tier = (over: Partial<ITicket> = {}): ITicket => ({
29
+ id: over.id ?? "t1",
30
+ title: over.title ?? "GA",
31
+ description: "",
32
+ price: 20,
33
+ quantity: 100,
34
+ order: 0,
35
+ sold: 0,
36
+ maxPerPerson: 10,
37
+ ...over,
38
+ });
39
+
40
+ const ga = tier({ id: "ga", title: "General Admission" });
41
+ const vip = tier({ id: "vip", title: "VIP" });
42
+
43
+ const slots = (input: {
44
+ quantities: Record<string, number>;
45
+ names?: AttendeeNames;
46
+ buyerName?: string;
47
+ tickets?: ITicket[];
48
+ }) =>
49
+ buildAttendeeSlots({
50
+ tickets: input.tickets ?? [ga, vip],
51
+ quantities: input.quantities,
52
+ names: input.names ?? {},
53
+ buyerName: input.buyerName ?? "",
54
+ });
55
+
56
+ describe("attendee helpers", () => {
57
+ describe("collectsAttendees", () => {
58
+ it("is false when the field is absent — every event that predates the feature", () => {
59
+ expect(collectsAttendees({} as never)).toBe(false);
60
+ expect(collectsAttendees(null)).toBe(false);
61
+ expect(collectsAttendees(undefined)).toBe(false);
62
+ });
63
+
64
+ it("is true only when the host actually turned it on", () => {
65
+ expect(collectsAttendees({ collectAttendeeDetails: true })).toBe(true);
66
+ expect(collectsAttendees({ collectAttendeeDetails: false })).toBe(false);
67
+ });
68
+ });
69
+
70
+ describe("normalizeAttendeeName", () => {
71
+ it("collapses whitespace so one person cannot sort into two door-list rows", () => {
72
+ expect(normalizeAttendeeName(" Jane Doe ")).toBe("Jane Doe");
73
+ });
74
+
75
+ it("treats an all-whitespace name as empty", () => {
76
+ expect(normalizeAttendeeName(" ")).toBe("");
77
+ expect(normalizeAttendeeName(undefined)).toBe("");
78
+ });
79
+ });
80
+
81
+ describe("buyerFullName", () => {
82
+ it("joins the two halves the checkout already collected", () => {
83
+ expect(buyerFullName({ firstName: "Jane", lastName: "Doe" })).toBe("Jane Doe");
84
+ });
85
+
86
+ it("is empty before the buyer has typed anything — not a stray space", () => {
87
+ expect(buyerFullName({ firstName: "", lastName: "" })).toBe("");
88
+ expect(buyerFullName({})).toBe("");
89
+ });
90
+ });
91
+
92
+ describe("buildAttendeeSlots", () => {
93
+ it("makes one slot per seat, in tier then issue order", () => {
94
+ const built = slots({ quantities: { ga: 2, vip: 1 } });
95
+ expect(built.map((s) => [s.ticketId, s.index])).toEqual([
96
+ ["ga", 0],
97
+ ["ga", 1],
98
+ ["vip", 0],
99
+ ]);
100
+ });
101
+
102
+ it("skips a tier with no seats selected", () => {
103
+ expect(slots({ quantities: { vip: 1 } }).map((s) => s.ticketId)).toEqual(["vip"]);
104
+ });
105
+
106
+ it("pre-fills ONLY the first seat with the buyer, so nothing known is retyped", () => {
107
+ const built = slots({ quantities: { ga: 2 }, buyerName: "Jane Doe" });
108
+ expect(built[0]).toMatchObject({ isBuyer: true, value: "Jane Doe" });
109
+ expect(built[1]).toMatchObject({ isBuyer: false, value: "" });
110
+ });
111
+
112
+ it("keeps a first seat the buyer deliberately CLEARED empty", () => {
113
+ // "" is a decision; `undefined` is "untouched". Snapping a cleared field
114
+ // back to the buyer's name would overrule the person filling the form.
115
+ const built = slots({ quantities: { ga: 1 }, names: { ga: [""] }, buyerName: "Jane Doe" });
116
+ expect(built[0].value).toBe("");
117
+ });
118
+
119
+ it("labels each seat with its tier, and numbers them only when there is more than one", () => {
120
+ expect(slots({ quantities: { vip: 1 } })[0].label).toBe("VIP");
121
+ expect(slots({ quantities: { ga: 3 } }).map((s) => s.label)).toEqual([
122
+ "General Admission — Attendee 1 of 3",
123
+ "General Admission — Attendee 2 of 3",
124
+ "General Admission — Attendee 3 of 3",
125
+ ]);
126
+ });
127
+
128
+ it("drops names past the current quantity — no orphan when the buyer reduces the cart", () => {
129
+ const names: AttendeeNames = { ga: ["A", "B", "C", "D"] };
130
+ const built = slots({ quantities: { ga: 2 }, names });
131
+ expect(built.map((s) => s.value)).toEqual(["A", "B"]);
132
+ // And going back up restores what was already typed rather than blanking it.
133
+ expect(slots({ quantities: { ga: 4 }, names }).map((s) => s.value)).toEqual(["A", "B", "C", "D"]);
134
+ });
135
+
136
+ it("ignores a negative or fractional quantity rather than producing junk seats", () => {
137
+ expect(slots({ quantities: { ga: -3 } })).toHaveLength(0);
138
+ expect(slots({ quantities: { ga: 2.7 } })).toHaveLength(2);
139
+ });
140
+ });
141
+
142
+ describe("setAttendeeName", () => {
143
+ it("writes one seat without disturbing the others", () => {
144
+ const next = setAttendeeName({ ga: ["A", "B"] }, "ga", 1, "Bee");
145
+ expect(next.ga).toEqual(["A", "Bee"]);
146
+ });
147
+
148
+ it("leaves earlier untouched seats as `undefined`, not as holes or empty strings", () => {
149
+ const next = setAttendeeName({}, "ga", 2, "C");
150
+ expect(next.ga).toEqual([undefined, undefined, "C"]);
151
+ // A hole would serialise to `null`; these must stay "not typed yet" so the
152
+ // first seat can still show the buyer default.
153
+ expect(0 in (next.ga as unknown[])).toBe(true);
154
+ });
155
+
156
+ it("does not mutate the previous state", () => {
157
+ const prev: AttendeeNames = { ga: ["A"] };
158
+ setAttendeeName(prev, "ga", 0, "Z");
159
+ expect(prev.ga).toEqual(["A"]);
160
+ });
161
+ });
162
+
163
+ describe("validateAttendeeNames", () => {
164
+ it("passes an empty list — an event that does not ask has nothing to validate", () => {
165
+ expect(validateAttendeeNames([])).toEqual({ ok: true });
166
+ });
167
+
168
+ it("refuses a blank seat and names WHICH one", () => {
169
+ const built = slots({ quantities: { ga: 2 }, names: { ga: ["Jane"] } });
170
+ const result = validateAttendeeNames(built);
171
+ expect(result.ok).toBe(false);
172
+ if (result.ok) return;
173
+ expect(result.reason).toBe("name_required");
174
+ expect(result.slot.index).toBe(1);
175
+ expect(result.message).toContain("General Admission — Attendee 2 of 2");
176
+ });
177
+
178
+ it("refuses whitespace-only, which would otherwise write an empty owner_name", () => {
179
+ const built = slots({ quantities: { ga: 1 }, names: { ga: [" "] } });
180
+ expect(validateAttendeeNames(built).ok).toBe(false);
181
+ });
182
+
183
+ it("refuses a name over the server's cap rather than letting the request 400", () => {
184
+ const built = slots({ quantities: { ga: 1 }, names: { ga: ["x".repeat(MAX_ATTENDEE_NAME_LENGTH + 1)] } });
185
+ const result = validateAttendeeNames(built);
186
+ expect(result.ok).toBe(false);
187
+ if (result.ok) return;
188
+ expect(result.reason).toBe("name_too_long");
189
+ });
190
+
191
+ it("accepts a name exactly at the cap", () => {
192
+ const built = slots({ quantities: { ga: 1 }, names: { ga: ["x".repeat(MAX_ATTENDEE_NAME_LENGTH)] } });
193
+ expect(validateAttendeeNames(built)).toEqual({ ok: true });
194
+ });
195
+
196
+ it("accepts the buyer default on an untouched first seat — that IS an answer", () => {
197
+ const built = slots({ quantities: { ga: 1 }, buyerName: "Jane Doe" });
198
+ expect(validateAttendeeNames(built)).toEqual({ ok: true });
199
+ });
200
+ });
201
+
202
+ describe("attendeesPayload", () => {
203
+ it("is undefined with no slots, so the field is omitted from the body entirely", () => {
204
+ expect(attendeesPayload([])).toBeUndefined();
205
+ });
206
+
207
+ it("keys by ticket id with exactly `quantity` entries per tier, in issue order", () => {
208
+ const built = slots({ quantities: { ga: 2, vip: 1 }, names: { ga: ["Jane", "John"], vip: ["Ada"] } });
209
+ expect(attendeesPayload(built)).toEqual({
210
+ ga: [{ name: "Jane" }, { name: "John" }],
211
+ vip: [{ name: "Ada" }],
212
+ });
213
+ });
214
+
215
+ it("sends the buyer's own name for the untouched first seat", () => {
216
+ const built = slots({ quantities: { ga: 2 }, names: { ga: [undefined, "John"] }, buyerName: "Jane Doe" });
217
+ expect(attendeesPayload(built)).toEqual({ ga: [{ name: "Jane Doe" }, { name: "John" }] });
218
+ });
219
+
220
+ it("normalises before sending, so the door list matches what the server stores", () => {
221
+ const built = slots({ quantities: { ga: 1 }, names: { ga: [" Jane Doe "] } });
222
+ expect(attendeesPayload(built)).toEqual({ ga: [{ name: "Jane Doe" }] });
223
+ });
224
+
225
+ it("never sends an email — a per-attendee address would hand the pass away", () => {
226
+ const built = slots({ quantities: { ga: 1 }, names: { ga: ["Jane"] } });
227
+ const payload = attendeesPayload(built)!;
228
+ expect(Object.keys(payload.ga[0])).toEqual(["name"]);
229
+ });
230
+ });
231
+ });
@@ -0,0 +1,220 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ buildMembershipGateNotice,
4
+ isMembershipGateLocked,
5
+ joinTierNames,
6
+ membershipSignInHref,
7
+ parseMembershipGateError,
8
+ type PublicMembershipGate,
9
+ } from "../membershipGate";
10
+
11
+ /**
12
+ * The members-only rendering rule, shared by BOTH storefront stacks.
13
+ *
14
+ * These tests exist because the two halves of the gate are trivially easy to
15
+ * swap and the swap is invisible: both produce a plausible-looking notice, and
16
+ * only a real buyer discovers that one of them is a dead end (a signed-in
17
+ * member told to sign in) and the other asks for money that has already been
18
+ * paid.
19
+ */
20
+
21
+ const gate = (over: Partial<PublicMembershipGate> = {}): PublicMembershipGate => ({
22
+ requiredTierIds: ["gold"],
23
+ allowed: false,
24
+ reason: "membership_required",
25
+ ...over,
26
+ });
27
+
28
+ const apiError = (data: unknown) => ({ response: { data } });
29
+
30
+ describe("isMembershipGateLocked", () => {
31
+ it("an ABSENT gate is not a lock", () => {
32
+ // Every read returns null while the switch is off. Treating absent as
33
+ // "gated" would badge every item on every site the day the field shipped.
34
+ expect(isMembershipGateLocked(null)).toBe(false);
35
+ expect(isMembershipGateLocked(undefined)).toBe(false);
36
+ });
37
+
38
+ it("an allowed gate is not a lock, a refused one is", () => {
39
+ expect(isMembershipGateLocked(gate({ allowed: true, reason: null, requiredTierIds: [] }))).toBe(false);
40
+ expect(isMembershipGateLocked(gate())).toBe(true);
41
+ });
42
+ });
43
+
44
+ describe("parseMembershipGateError", () => {
45
+ it("ignores anything that is not a MEMBERSHIP_GATE", () => {
46
+ expect(parseMembershipGateError(new Error("boom"))).toBeNull();
47
+ expect(parseMembershipGateError(apiError({ code: "INVENTORY_HOLD_EXPIRED" }))).toBeNull();
48
+ expect(parseMembershipGateError(apiError({ message: "Sold out" }))).toBeNull();
49
+ expect(parseMembershipGateError(undefined)).toBeNull();
50
+ });
51
+
52
+ it("unpacks details and keeps the API's own sentence", () => {
53
+ expect(
54
+ parseMembershipGateError(
55
+ apiError({
56
+ code: "MEMBERSHIP_GATE",
57
+ message: "Members only: “Front row” is for members.",
58
+ details: {
59
+ reason: "membership_required",
60
+ requiredTierIds: ["gold", "platinum"],
61
+ targetType: "event_ticket",
62
+ targetId: "tick-1",
63
+ },
64
+ }),
65
+ ),
66
+ ).toEqual({
67
+ reason: "membership_required",
68
+ requiredTierIds: ["gold", "platinum"],
69
+ targetType: "event_ticket",
70
+ targetId: "tick-1",
71
+ message: "Members only: “Front row” is for members.",
72
+ });
73
+ });
74
+
75
+ it("matches on CODE, not on status — a read refusal is 401/403, a checkout refusal 400", () => {
76
+ // Nothing here reads a status at all; the same payload parses whichever
77
+ // status carried it. A client switching on 401 would render this as a
78
+ // session timeout.
79
+ const parsed = parseMembershipGateError(
80
+ apiError({ code: "MEMBERSHIP_GATE", details: { reason: "sign_in_required" } }),
81
+ );
82
+ expect(parsed?.reason).toBe("sign_in_required");
83
+ });
84
+
85
+ it("falls back to membership_required on an unknown reason", () => {
86
+ // The safe half: it offers a way to BUY access. A wrong `sign_in_required`
87
+ // offers a signed-in member a button that changes nothing.
88
+ expect(parseMembershipGateError(apiError({ code: "MEMBERSHIP_GATE", details: { reason: "wat" } }))?.reason).toBe(
89
+ "membership_required",
90
+ );
91
+ expect(parseMembershipGateError(apiError({ code: "MEMBERSHIP_GATE" }))?.reason).toBe("membership_required");
92
+ });
93
+
94
+ it("survives a malformed details block without throwing", () => {
95
+ expect(
96
+ parseMembershipGateError(
97
+ apiError({ code: "MEMBERSHIP_GATE", details: { requiredTierIds: "gold", targetId: 7 } }),
98
+ ),
99
+ ).toEqual({
100
+ reason: "membership_required",
101
+ requiredTierIds: [],
102
+ targetType: "",
103
+ targetId: "",
104
+ message: null,
105
+ });
106
+ });
107
+ });
108
+
109
+ describe("joinTierNames", () => {
110
+ it("joins with OR, because any one tier is enough", () => {
111
+ expect(joinTierNames(["Gold"])).toBe("Gold");
112
+ expect(joinTierNames(["Gold", "Platinum"])).toBe("Gold or Platinum");
113
+ expect(joinTierNames(["Gold", "Silver", "Bronze"])).toBe("Gold, Silver or Bronze");
114
+ });
115
+
116
+ it("drops blanks — an unresolved tier must not become an empty slot", () => {
117
+ expect(joinTierNames(["", " ", "Gold"])).toBe("Gold");
118
+ expect(joinTierNames([])).toBe("");
119
+ });
120
+ });
121
+
122
+ describe("membershipSignInHref", () => {
123
+ it("returns the buyer to what they were trying to buy", () => {
124
+ expect(membershipSignInHref("/i/login", "/i/events/spring-tour")).toBe(
125
+ "/i/login?redirect=%2Fi%2Fevents%2Fspring-tour",
126
+ );
127
+ });
128
+
129
+ it("refuses an off-site redirect target", () => {
130
+ expect(membershipSignInHref("/login", "https://evil.example")).toBe("/login");
131
+ expect(membershipSignInHref("/login", "//evil.example")).toBe("/login");
132
+ expect(membershipSignInHref("/login", "")).toBe("/login");
133
+ });
134
+ });
135
+
136
+ describe("buildMembershipGateNotice", () => {
137
+ it("says nothing when nothing is gated", () => {
138
+ expect(buildMembershipGateNotice({})).toBeNull();
139
+ expect(buildMembershipGateNotice({ gate: null })).toBeNull();
140
+ expect(buildMembershipGateNotice({ gate: gate({ allowed: true, reason: null }) })).toBeNull();
141
+ });
142
+
143
+ it("offers SIGN IN to a visitor we cannot identify, and returns them here", () => {
144
+ const notice = buildMembershipGateNotice({
145
+ gate: gate({ reason: "sign_in_required" }),
146
+ tierNames: ["Gold"],
147
+ itemLabel: "This ticket",
148
+ loginPath: "/i/login",
149
+ currentPath: "/i/events/spring-tour",
150
+ });
151
+
152
+ expect(notice).toMatchObject({
153
+ reason: "sign_in_required",
154
+ badge: "Members only",
155
+ actionLabel: "Sign in",
156
+ actionHref: "/i/login?redirect=%2Fi%2Fevents%2Fspring-tour",
157
+ });
158
+ // The possibility that they already ARE a member is the funnel; without it
159
+ // this is just a locked door.
160
+ expect(notice!.body).toBe("This ticket is for Gold members. Already one? Sign in and it unlocks.");
161
+ });
162
+
163
+ it("offers the TIER to someone already signed in — never sign-in again", () => {
164
+ const notice = buildMembershipGateNotice({
165
+ gate: gate({ reason: "membership_required", requiredTierIds: ["gold", "plat"] }),
166
+ tierNames: ["Gold", "Platinum"],
167
+ itemLabel: "This course",
168
+ membershipPath: "/i/membership",
169
+ });
170
+
171
+ expect(notice).toMatchObject({
172
+ reason: "membership_required",
173
+ actionLabel: "View Gold or Platinum",
174
+ actionHref: "/i/membership",
175
+ });
176
+ expect(notice!.body).toBe("This course is for Gold or Platinum members. Your current plan doesn't include it.");
177
+ // Telling a signed-in member to sign in is a loop with no exit.
178
+ expect(notice!.actionLabel).not.toMatch(/sign in/i);
179
+ });
180
+
181
+ it("still reads as a sentence when the tier names have not resolved", () => {
182
+ // The tier list is a second request; it can be in flight, or the gating tier
183
+ // can be one the public list does not carry. An id is not a sentence.
184
+ const notice = buildMembershipGateNotice({ gate: gate(), itemLabel: "This ticket" });
185
+ expect(notice!.body).toBe("This ticket is for members. Your current plan doesn't include it.");
186
+ expect(notice!.actionLabel).toBe("View membership");
187
+ expect(notice!.body).not.toContain("gold");
188
+ });
189
+
190
+ it("falls back to a generic subject with no item label", () => {
191
+ expect(buildMembershipGateNotice({ gate: gate() })!.body).toBe(
192
+ "This is for members. Your current plan doesn't include it.",
193
+ );
194
+ });
195
+
196
+ it("a REFUSAL overrides a stale announced gate", () => {
197
+ // The page rendered `allowed: true` from cache, or the membership lapsed
198
+ // between page load and pay. The server's no at the moment of purchase is
199
+ // what actually happened.
200
+ const notice = buildMembershipGateNotice({
201
+ gate: gate({ allowed: true, reason: null, requiredTierIds: [] }),
202
+ refusal: {
203
+ reason: "membership_required",
204
+ requiredTierIds: ["gold"],
205
+ targetType: "event_ticket",
206
+ targetId: "tick-1",
207
+ message: "Members only.",
208
+ },
209
+ tierNames: ["Gold"],
210
+ itemLabel: "This ticket",
211
+ });
212
+
213
+ expect(notice).toMatchObject({ reason: "membership_required", actionLabel: "View Gold" });
214
+ });
215
+
216
+ it("uses default paths when the host names none", () => {
217
+ expect(buildMembershipGateNotice({ gate: gate() })!.actionHref).toBe("/membership");
218
+ expect(buildMembershipGateNotice({ gate: gate({ reason: "sign_in_required" }) })!.actionHref).toBe("/login");
219
+ });
220
+ });