@tribe-nest/forge 3.20.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.
- package/package.json +1 -1
- package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
- package/src/data/queries/useAccountSettings.ts +15 -2
- package/src/data/queries/useAuthActions.ts +51 -7
- package/src/data/queries/useEvents.ts +66 -4
- package/src/data/queries/useMembership.ts +8 -2
- package/src/data/queries/useMyBookings.ts +12 -0
- package/src/data/queries/useMyTickets.ts +112 -0
- package/src/data/queries/useOrders.ts +10 -0
- package/src/data/queries/usePassTransfers.ts +73 -13
- package/src/server/index.ts +52 -0
- package/src/types/models.ts +151 -0
- package/src/ui/format/_tests/attendees.spec.ts +231 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
- package/src/ui/format/attendees.ts +187 -0
- package/src/ui/format/membershipGate.ts +209 -0
- package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
- package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
- package/src/ui/headless/checkout/inventoryHold.ts +83 -0
- package/src/ui/headless/checkout/useCheckout.ts +72 -0
- package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
- package/src/ui/headless/event/useEventCheckout.ts +133 -2
- package/src/ui/headless/event/usePresaleCode.ts +181 -0
- package/src/ui/headless/index.ts +25 -0
- package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
- package/src/ui/headless/offer/OfferContext.tsx +55 -0
- package/src/ui/index.ts +42 -0
- package/src/ui/styled/AccountDashboard.tsx +70 -8
- package/src/ui/styled/AddToCalendar.tsx +34 -10
- package/src/ui/styled/Checkout.tsx +18 -1
- package/src/ui/styled/CoachingConfirmation.tsx +4 -0
- package/src/ui/styled/CourseDetail.tsx +30 -1
- package/src/ui/styled/EventConfirmation.tsx +2 -0
- package/src/ui/styled/EventDetail.tsx +53 -22
- package/src/ui/styled/EventTickets.tsx +156 -5
- package/src/ui/styled/HoldNotice.tsx +192 -0
- package/src/ui/styled/MembershipGateNotice.tsx +159 -0
- package/src/ui/styled/OfferButton.tsx +23 -0
- package/src/ui/styled/PresaleCode.tsx +174 -0
- package/src/ui/styled/ProductDetail.tsx +75 -5
- package/src/ui/styled/ProductGrid.tsx +26 -0
- package/src/ui/styled/TicketTransfer.tsx +69 -40
- package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
- package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
- package/src/utils/_tests/presaleCode.spec.ts +168 -0
- package/src/utils/_tests/structuredData.spec.ts +275 -0
- package/src/utils/presaleCode.ts +96 -0
- package/src/utils/structuredData.ts +361 -27
|
@@ -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
|
+
});
|
|
@@ -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
|
+
}
|