@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
package/src/server/index.ts
CHANGED
|
@@ -47,6 +47,14 @@ import type {
|
|
|
47
47
|
CollectionSearchResult,
|
|
48
48
|
} from "../types/models";
|
|
49
49
|
import { collectionParamsToQuery, splitCollectionQuery } from "../data/collectionParams";
|
|
50
|
+
// SEO context for structured data. Pure functions over plain data, so this is
|
|
51
|
+
// safe in the React-free server entry.
|
|
52
|
+
import {
|
|
53
|
+
seoContextFromSiteConfig,
|
|
54
|
+
type ReviewSchemaReview,
|
|
55
|
+
type SiteSeoContext,
|
|
56
|
+
} from "../utils/structuredData";
|
|
57
|
+
export type { SiteSeoContext };
|
|
50
58
|
import type { SiteConfig } from "../data/queries/useWebsite";
|
|
51
59
|
|
|
52
60
|
/**
|
|
@@ -121,6 +129,50 @@ export function fetchSiteConfig(opts: { apiUrl: string; profileId?: string }): P
|
|
|
121
129
|
return getJson<SiteConfig>(opts.apiUrl, "/public/websites/site-config", { profileId: opts.profileId });
|
|
122
130
|
}
|
|
123
131
|
|
|
132
|
+
/**
|
|
133
|
+
* A sample of an entity's published reviews, for the `review` array in its
|
|
134
|
+
* JSON-LD. Sorted by "helpful" so the sample Google reads is the useful one.
|
|
135
|
+
*
|
|
136
|
+
* Purely an enrichment: the star snippet comes from `aggregateRating`, which
|
|
137
|
+
* every detail payload already carries. Only call this when the entity HAS
|
|
138
|
+
* reviews — the sample is worth one round trip for a reviewed thing and worth
|
|
139
|
+
* none for an unreviewed one.
|
|
140
|
+
*/
|
|
141
|
+
export async function fetchEntityReviewsServer(opts: {
|
|
142
|
+
apiUrl: string;
|
|
143
|
+
profileId?: string;
|
|
144
|
+
entityType: "product" | "course" | "coaching_product";
|
|
145
|
+
entityId: string;
|
|
146
|
+
limit?: number;
|
|
147
|
+
}): Promise<ReviewSchemaReview[]> {
|
|
148
|
+
if (!opts.profileId) return [];
|
|
149
|
+
const res = await getJson<{ data: ReviewSchemaReview[] }>(opts.apiUrl, "/public/reviews", {
|
|
150
|
+
profileId: opts.profileId,
|
|
151
|
+
entityType: opts.entityType,
|
|
152
|
+
entityId: opts.entityId,
|
|
153
|
+
page: "1",
|
|
154
|
+
limit: String(opts.limit ?? 5),
|
|
155
|
+
sort: "helpful",
|
|
156
|
+
});
|
|
157
|
+
return res?.data ?? [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The tenant facts the JSON-LD builders need (settlement currency + creator
|
|
162
|
+
* name), for a detail route's OWN loader.
|
|
163
|
+
*
|
|
164
|
+
* Fetched per route rather than read off the root match on purpose: during SSR
|
|
165
|
+
* a child `head()` sees the root match with `loaderData: null`, so the root's
|
|
166
|
+
* site config is invisible there. Pair it with the entity fetch in one
|
|
167
|
+
* `Promise.all` and the extra round trip costs nothing wall-clock.
|
|
168
|
+
*/
|
|
169
|
+
export async function fetchSeoContextServer(opts: {
|
|
170
|
+
apiUrl: string;
|
|
171
|
+
profileId?: string;
|
|
172
|
+
}): Promise<SiteSeoContext> {
|
|
173
|
+
return seoContextFromSiteConfig(await fetchSiteConfig(opts));
|
|
174
|
+
}
|
|
175
|
+
|
|
124
176
|
/** The API's unauthenticated liveness route — no tenant, no params, no body. */
|
|
125
177
|
const PROBE_PATH = "/healthcheck";
|
|
126
178
|
|
package/src/types/models.ts
CHANGED
|
@@ -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
|
+
});
|