@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,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
+ });