@session-services/web-elements 0.1.19 → 0.1.21

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 (32) hide show
  1. package/dist/cjs/{index-C6e98Atu.js → index-BYBgrATL.js} +1 -0
  2. package/dist/cjs/loader.cjs.js +2 -2
  3. package/dist/cjs/session-services-elements.cjs.js +2 -2
  4. package/dist/cjs/session-services-formatted-text.cjs.entry.js +181 -165
  5. package/dist/cjs/session-services-ticketing.cjs.entry.js +24398 -19366
  6. package/dist/collection/components/session-services-formatted-text/session-services-formatted-text.js +33 -34
  7. package/dist/collection/components/session-services-ticketing/session-services-ticketing.css +401 -339
  8. package/dist/collection/components/session-services-ticketing/session-services-ticketing.js +1087 -269
  9. package/dist/collection/components/session-services-ticketing/session-services-ticketing.logic.js +263 -0
  10. package/dist/collection/components/session-services-ticketing/session-services-ticketing.logic.test.js +276 -0
  11. package/dist/collection/components/session-services-ticketing/session-services-ticketing.test.js +264 -0
  12. package/dist/components/session-services-formatted-text.js +180 -164
  13. package/dist/components/session-services-ticketing.js +24442 -19392
  14. package/dist/esm/{index-CeXOR4aK.js → index-CNOpF6tk.js} +1 -1
  15. package/dist/esm/loader.js +3 -3
  16. package/dist/esm/session-services-elements.js +3 -3
  17. package/dist/esm/session-services-formatted-text.entry.js +181 -165
  18. package/dist/esm/session-services-ticketing.entry.js +24406 -19374
  19. package/dist/session-services-elements/{p-a2404c46.entry.js → p-703444ed.entry.js} +1 -1
  20. package/dist/session-services-elements/p-CNOpF6tk.js +2 -0
  21. package/dist/session-services-elements/p-b8703bf3.entry.js +1 -0
  22. package/dist/session-services-elements/session-services-elements.esm.js +1 -1
  23. package/dist/sst-env.d.ts +1 -0
  24. package/dist/types/components/session-services-formatted-text/session-services-formatted-text.d.ts +3 -0
  25. package/dist/types/components/session-services-ticketing/session-services-ticketing.d.ts +117 -3
  26. package/dist/types/components/session-services-ticketing/session-services-ticketing.logic.d.ts +193 -0
  27. package/dist/types/components/session-services-ticketing/session-services-ticketing.logic.test.d.ts +1 -0
  28. package/dist/types/components/session-services-ticketing/session-services-ticketing.test.d.ts +1 -0
  29. package/dist/types/components.d.ts +18 -0
  30. package/package.json +8 -7
  31. package/dist/session-services-elements/p-62a8e2cc.entry.js +0 -1
  32. package/dist/session-services-elements/p-CeXOR4aK.js +0 -2
@@ -0,0 +1,263 @@
1
+ import { formatNaiveDateTime, isNaiveDateTimeFuture, isNaiveDateTimePast, } from "@session-services/utils/naive-datetime";
2
+ import { createQuoteItemMap } from "@session-services/utils/ticket-quote";
3
+ import { getAdmissionSelectionCapacity, isAdmissionSoldOut, } from "@session-services/utils/ticket-availability";
4
+ const UNLIMITED_CAP = 10;
5
+ const SCARCITY_THRESHOLD = 20;
6
+ export function selectionKey(admissionId, sectionId) {
7
+ return `${admissionId}::${sectionId || "none"}`;
8
+ }
9
+ export function parseSelectionKey(key) {
10
+ const [admissionId, sectionId] = key.split("::");
11
+ return {
12
+ admissionId,
13
+ sectionId: sectionId === "none" ? undefined : sectionId,
14
+ };
15
+ }
16
+ export function priceTotal(price) {
17
+ if (!price)
18
+ return 0;
19
+ if ("total" in price && typeof price.total === "number")
20
+ return price.total;
21
+ return (price.base ?? 0) + (price.booking ?? 0);
22
+ }
23
+ export function discountCouponFromSearch(search) {
24
+ return new URLSearchParams(search).get("discount") ?? "";
25
+ }
26
+ export function eventTimeZone(event) {
27
+ return event.location?.timeZone ?? "UTC";
28
+ }
29
+ function eventEnded(event) {
30
+ return isNaiveDateTimePast(event.entryEndsAt, eventTimeZone(event));
31
+ }
32
+ function sectionsById(event) {
33
+ return new Map(event.sections.map((section) => [section.id, section]));
34
+ }
35
+ function hasAdmissionSection(admission, sections) {
36
+ if (!admission.sectionId)
37
+ return true;
38
+ return sections.has(admission.sectionId);
39
+ }
40
+ function admissionSection(admission, sections) {
41
+ if (!admission.sectionId)
42
+ return undefined;
43
+ return sections.get(admission.sectionId);
44
+ }
45
+ function isAdmissionOnSale(admission, zone) {
46
+ if (isNaiveDateTimeFuture(admission.saleStartsAt, zone))
47
+ return false;
48
+ return !isNaiveDateTimePast(admission.saleEndsAt, zone);
49
+ }
50
+ function hasAdmissionCapacity(admission, section) {
51
+ return getAdmissionSelectionCapacity({ admission, section }) > 0;
52
+ }
53
+ function isAdmissionPurchasable(admission, zone, sections) {
54
+ if (!hasAdmissionSection(admission, sections))
55
+ return false;
56
+ if (!isAdmissionOnSale(admission, zone))
57
+ return false;
58
+ return hasAdmissionCapacity(admission, admissionSection(admission, sections));
59
+ }
60
+ function hasPurchasableAdmissions(event) {
61
+ const sections = sectionsById(event);
62
+ const zone = eventTimeZone(event);
63
+ return event.admissions.some((admission) => isAdmissionPurchasable(admission, zone, sections));
64
+ }
65
+ function postSaleLabel(value) {
66
+ if (value === "SOLD_OUT")
67
+ return "Sold out";
68
+ if (value === "DOOR")
69
+ return "Tickets available at the door";
70
+ return "Sale ended";
71
+ }
72
+ function postSaleTone(value) {
73
+ if (value === "DOOR")
74
+ return "success";
75
+ return "muted";
76
+ }
77
+ function admissionSaleLabel(admission, zone) {
78
+ if (isNaiveDateTimePast(admission.saleEndsAt, zone)) {
79
+ return {
80
+ text: postSaleLabel(admission.saleEndsBehavior),
81
+ tone: postSaleTone(admission.saleEndsBehavior),
82
+ };
83
+ }
84
+ if (!isNaiveDateTimeFuture(admission.saleStartsAt, zone)) {
85
+ return null;
86
+ }
87
+ return {
88
+ text: `On sale ${formatNaiveDateTime(admission.saleStartsAt, "EEE, MMM d · h:mm a")}`,
89
+ tone: "muted",
90
+ };
91
+ }
92
+ function endedStatus(admissions, zone) {
93
+ const ended = admissions.filter((admission) => isNaiveDateTimePast(admission.saleEndsAt, zone));
94
+ if (ended.length === 0)
95
+ return undefined;
96
+ if (ended.some((admission) => admission.saleEndsBehavior === "DOOR"))
97
+ return "DOOR";
98
+ if (ended.every((admission) => admission.saleEndsBehavior === "SOLD_OUT"))
99
+ return "SOLD_OUT";
100
+ return "SALE_ENDED";
101
+ }
102
+ function salesStatus(event) {
103
+ const sections = sectionsById(event);
104
+ const admissions = event.admissions.filter((admission) => admission.visibility !== "HIDDEN" &&
105
+ hasAdmissionSection(admission, sections));
106
+ if (admissions.length === 0)
107
+ return undefined;
108
+ if (hasPurchasableAdmissions(event))
109
+ return "ONLINE";
110
+ return endedStatus(admissions, eventTimeZone(event));
111
+ }
112
+ function hasDisplayableAdmissions(event) {
113
+ const sections = sectionsById(event);
114
+ return event.admissions.some((admission) => hasAdmissionSection(admission, sections));
115
+ }
116
+ function isDoorOnly(event) {
117
+ if (hasPurchasableAdmissions(event))
118
+ return false;
119
+ return salesStatus(event) === "DOOR";
120
+ }
121
+ export function getTicketingState(event) {
122
+ const ended = eventEnded(event);
123
+ const externalTicketUrl = event.url || undefined;
124
+ const doorOnly = isDoorOnly(event);
125
+ const showExternalTicketing = !ended && Boolean(externalTicketUrl);
126
+ const showTicketing = !ended && !showExternalTicketing && hasDisplayableAdmissions(event);
127
+ return {
128
+ externalTicketUrl,
129
+ isDoorOnly: doorOnly,
130
+ isEnded: ended,
131
+ isUnavailableForSale: !doorOnly && !hasPurchasableAdmissions(event) && showTicketing,
132
+ showExternalTicketing,
133
+ showTicketing,
134
+ ticketHeading: doorOnly ? "Tickets at the Door" : "Get Tickets",
135
+ };
136
+ }
137
+ export function visibleAdmissions(event, mode) {
138
+ if (mode !== "door")
139
+ return event.admissions;
140
+ const zone = eventTimeZone(event);
141
+ return event.admissions.filter((admission) => admission.saleEndsBehavior === "DOOR" &&
142
+ isNaiveDateTimePast(admission.saleEndsAt, zone));
143
+ }
144
+ function sectionItems(sections, admissions) {
145
+ return sections
146
+ .map((section) => ({
147
+ type: "section",
148
+ section,
149
+ admissions: admissions
150
+ .filter((admission) => admission.sectionId === section.id)
151
+ .sort((a, b) => a.order - b.order),
152
+ }))
153
+ .filter((item) => item.admissions.length > 0);
154
+ }
155
+ function topLevelOrder(item) {
156
+ if (item.type === "section")
157
+ return item.section.order;
158
+ return item.admission.order;
159
+ }
160
+ export function topLevelTicketItems(sections, admissions) {
161
+ return [
162
+ ...sectionItems(sections, admissions),
163
+ ...admissions
164
+ .filter((admission) => !admission.sectionId)
165
+ .map((admission) => ({ type: "admission", admission })),
166
+ ].sort((a, b) => topLevelOrder(a) - topLevelOrder(b));
167
+ }
168
+ function isUnlimitedAdmission(admission, section) {
169
+ if ((admission.available ?? 0) !== -1)
170
+ return false;
171
+ if (admission.capacity !== -1)
172
+ return false;
173
+ return !section || (section.available ?? 0) === -1;
174
+ }
175
+ function maxQuantity(admission, section) {
176
+ if (isUnlimitedAdmission(admission, section))
177
+ return UNLIMITED_CAP;
178
+ return Math.min(getAdmissionSelectionCapacity({ admission, section }), UNLIMITED_CAP);
179
+ }
180
+ function showScarcity(admission, soldOut, disabled) {
181
+ return [
182
+ !soldOut,
183
+ !disabled,
184
+ (admission.available ?? 0) > 0,
185
+ (admission.available ?? 0) < SCARCITY_THRESHOLD,
186
+ ].every(Boolean);
187
+ }
188
+ export function getAdmissionDisplayState(input) {
189
+ const zone = eventTimeZone(input.event);
190
+ const saleNotStarted = isNaiveDateTimeFuture(input.admission.saleStartsAt, zone);
191
+ const saleEnded = isNaiveDateTimePast(input.admission.saleEndsAt, zone);
192
+ const soldOut = Boolean(input.sectionSoldOut) ||
193
+ isAdmissionSoldOut({
194
+ admission: input.admission,
195
+ section: input.section,
196
+ });
197
+ const disabled = soldOut || saleNotStarted || saleEnded;
198
+ return {
199
+ disabled,
200
+ label: admissionSaleLabel(input.admission, zone),
201
+ maxQuantity: maxQuantity(input.admission, input.section),
202
+ postSale: saleEnded,
203
+ showScarcity: showScarcity(input.admission, soldOut, disabled),
204
+ soldOut,
205
+ };
206
+ }
207
+ function quoteItemUnits(item) {
208
+ return Math.max(item?.quantity ?? 1, 1);
209
+ }
210
+ export function getAdmissionPriceDisplay(input) {
211
+ const item = input.quoteItem ?? input.previewItem;
212
+ if (item) {
213
+ const units = quoteItemUnits(item);
214
+ const total = Math.round(item.price.total / units);
215
+ if (!item.price.discount || item.price.discount >= 0) {
216
+ return {
217
+ discounted: false,
218
+ total,
219
+ };
220
+ }
221
+ return {
222
+ discounted: true,
223
+ gross: Math.round(item.price.gross / units),
224
+ total,
225
+ };
226
+ }
227
+ if (input.initialPrice && input.initialPrice.discount < 0) {
228
+ return {
229
+ discounted: true,
230
+ gross: input.initialPrice.gross,
231
+ total: input.initialPrice.total,
232
+ };
233
+ }
234
+ return {
235
+ discounted: false,
236
+ total: priceTotal(input.admission.price),
237
+ };
238
+ }
239
+ export function quoteItemsBySelection(quote) {
240
+ return createQuoteItemMap(quote);
241
+ }
242
+ export function admissionPriceSavings(price) {
243
+ if (!price.discounted || price.gross === undefined)
244
+ return 0;
245
+ return Math.max(price.gross - price.total, 0);
246
+ }
247
+ export function previewQuoteItems(event) {
248
+ const ids = new Set(event.sections.map((section) => section.id));
249
+ const zone = eventTimeZone(event);
250
+ return event.admissions
251
+ .filter((admission) => {
252
+ if (admission.sectionId && !ids.has(admission.sectionId))
253
+ return false;
254
+ if (isNaiveDateTimeFuture(admission.saleStartsAt, zone))
255
+ return false;
256
+ return !isNaiveDateTimePast(admission.saleEndsAt, zone);
257
+ })
258
+ .map((admission) => ({
259
+ admissionId: admission.id ?? "",
260
+ quantity: 1,
261
+ sectionId: admission.sectionId ?? undefined,
262
+ }));
263
+ }
@@ -0,0 +1,276 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { admissionPriceSavings, discountCouponFromSearch, getAdmissionDisplayState, getAdmissionPriceDisplay, getTicketingState, topLevelTicketItems, visibleAdmissions, } from "./session-services-ticketing.logic";
3
+ const futureStart = "2030-01-01T20:00:00";
4
+ const futureEnd = "2030-01-01T23:00:00";
5
+ const pastStart = "2020-01-01T20:00:00";
6
+ const pastEnd = "2020-01-01T23:00:00";
7
+ function admission(data = {}) {
8
+ return {
9
+ id: "adm_general",
10
+ name: "General Admission",
11
+ capacity: 100,
12
+ available: 100,
13
+ sold: 0,
14
+ reserved: 0,
15
+ limit: 10,
16
+ order: 1,
17
+ entryStartsAt: futureStart,
18
+ entryEndsAt: futureEnd,
19
+ saleStartsAt: pastStart,
20
+ saleEndsAt: futureEnd,
21
+ saleEndsBehavior: "SALE_ENDED",
22
+ price: {
23
+ base: 2000,
24
+ booking: 200,
25
+ currency: "AUD",
26
+ },
27
+ visibility: "ALWAYS",
28
+ teamId: "team_1",
29
+ promoterId: "pro_1",
30
+ eventId: "evt_1",
31
+ eventSlug: "event",
32
+ ...data,
33
+ };
34
+ }
35
+ function section(data = {}) {
36
+ return {
37
+ id: "sec_1",
38
+ name: "Main Room",
39
+ capacity: 100,
40
+ available: 100,
41
+ sold: 0,
42
+ reserved: 0,
43
+ limit: 10,
44
+ order: 2,
45
+ teamId: "team_1",
46
+ promoterId: "pro_1",
47
+ eventId: "evt_1",
48
+ eventSlug: "event",
49
+ location: {
50
+ id: "loc_1",
51
+ name: "Club Room",
52
+ address: "1 Test St",
53
+ city: "Sydney",
54
+ country: "AU",
55
+ latitude: -33.86,
56
+ longitude: 151.2,
57
+ timeZone: "Australia/Sydney",
58
+ createdAt: pastStart,
59
+ updatedAt: pastStart,
60
+ },
61
+ ...data,
62
+ };
63
+ }
64
+ function event(data = {}) {
65
+ return {
66
+ id: "evt_1",
67
+ name: "Test Event",
68
+ slug: "event",
69
+ currency: "AUD",
70
+ entryStartsAt: futureStart,
71
+ entryEndsAt: futureEnd,
72
+ publishAt: pastStart,
73
+ updatedAt: pastStart,
74
+ status: "UPCOMING",
75
+ visibility: "PUBLIC",
76
+ approval: "APPROVED",
77
+ teamId: "team_1",
78
+ promoterId: "pro_1",
79
+ admissions: [admission()],
80
+ sections: [],
81
+ images: {},
82
+ location: {
83
+ id: "loc_1",
84
+ name: "Club",
85
+ address: "1 Test St",
86
+ city: "Sydney",
87
+ country: "AU",
88
+ latitude: -33.86,
89
+ longitude: 151.2,
90
+ timeZone: "Australia/Sydney",
91
+ createdAt: pastStart,
92
+ updatedAt: pastStart,
93
+ },
94
+ ...data,
95
+ };
96
+ }
97
+ function quoteItem(data = {}) {
98
+ return {
99
+ id: "item_1",
100
+ kind: "ADMISSION",
101
+ admissionId: "adm_general",
102
+ quantity: 1,
103
+ price: {
104
+ base: 2000,
105
+ booking: 200,
106
+ currency: "AUD",
107
+ discount: -1000,
108
+ gross: 2200,
109
+ net: 1200,
110
+ refunded: 0,
111
+ total: 1200,
112
+ },
113
+ ...data,
114
+ };
115
+ }
116
+ describe("session-services-ticketing logic", () => {
117
+ it("reads discount coupon codes from query strings", () => {
118
+ expect(discountCouponFromSearch("?discount=save20")).toBe("save20");
119
+ expect(discountCouponFromSearch("?utm_source=test&discount=SAVE20")).toBe("SAVE20");
120
+ expect(discountCouponFromSearch("?coupon=SAVE20")).toBe("");
121
+ });
122
+ it("shows external ticketing instead of internal selection when an external URL exists", () => {
123
+ const state = getTicketingState(event({ url: "https://tickets.example" }));
124
+ expect(state.showExternalTicketing).toBe(true);
125
+ expect(state.showTicketing).toBe(false);
126
+ expect(state.externalTicketUrl).toBe("https://tickets.example");
127
+ });
128
+ it("detects door-only events and filters to door admissions", () => {
129
+ const door = admission({
130
+ id: "adm_door",
131
+ saleEndsAt: pastEnd,
132
+ saleEndsBehavior: "DOOR",
133
+ });
134
+ const ended = admission({
135
+ id: "adm_ended",
136
+ saleEndsAt: pastEnd,
137
+ saleEndsBehavior: "SALE_ENDED",
138
+ });
139
+ const data = event({ admissions: [door, ended] });
140
+ expect(getTicketingState(data).isDoorOnly).toBe(true);
141
+ expect(visibleAdmissions(data, "door").map((item) => item.id)).toEqual([
142
+ "adm_door",
143
+ ]);
144
+ });
145
+ it("disables admissions outside their online sale window", () => {
146
+ const data = event({
147
+ admissions: [
148
+ admission({
149
+ saleStartsAt: futureStart,
150
+ saleEndsAt: "2030-01-02T20:00:00",
151
+ }),
152
+ ],
153
+ });
154
+ const state = getAdmissionDisplayState({
155
+ admission: data.admissions[0],
156
+ event: data,
157
+ });
158
+ expect(state.disabled).toBe(true);
159
+ expect(state.label?.text).toContain("On sale");
160
+ });
161
+ it("orders top-level sections and unsectioned admissions together", () => {
162
+ const main = section({ id: "sec_main", order: 2 });
163
+ const early = admission({ id: "adm_early", order: 1 });
164
+ const sectioned = admission({
165
+ id: "adm_sectioned",
166
+ order: 1,
167
+ sectionId: main.id,
168
+ });
169
+ const items = topLevelTicketItems([main], [sectioned, early]);
170
+ expect(items.map((item) => item.type)).toEqual(["admission", "section"]);
171
+ });
172
+ it("caps unlimited admission quantity at the selector limit", () => {
173
+ const data = event({
174
+ admissions: [admission({ available: -1, capacity: -1 })],
175
+ });
176
+ expect(getAdmissionDisplayState({
177
+ admission: data.admissions[0],
178
+ event: data,
179
+ }).maxQuantity).toBe(10);
180
+ });
181
+ it("uses discounted quote pricing when available", () => {
182
+ const price = getAdmissionPriceDisplay({
183
+ admission: admission(),
184
+ mode: "default",
185
+ quoteItem: quoteItem(),
186
+ });
187
+ expect(price).toEqual({
188
+ discounted: true,
189
+ gross: 2200,
190
+ total: 1200,
191
+ });
192
+ });
193
+ it("uses quote item total pricing without discount styling", () => {
194
+ const price = getAdmissionPriceDisplay({
195
+ admission: admission(),
196
+ mode: "default",
197
+ quoteItem: quoteItem({
198
+ price: {
199
+ base: 2000,
200
+ booking: 200,
201
+ currency: "AUD",
202
+ discount: 0,
203
+ gross: 2200,
204
+ net: 2250,
205
+ refunded: 0,
206
+ total: 2250,
207
+ },
208
+ }),
209
+ });
210
+ expect(price).toEqual({
211
+ discounted: false,
212
+ total: 2250,
213
+ });
214
+ });
215
+ it("includes booking fees in admission prices for every mode", () => {
216
+ expect(getAdmissionPriceDisplay({
217
+ admission: admission(),
218
+ mode: "default",
219
+ }).total).toBe(2200);
220
+ expect(getAdmissionPriceDisplay({
221
+ admission: admission(),
222
+ mode: "door",
223
+ }).total).toBe(2200);
224
+ });
225
+ it("uses discounted initial preview pricing when available", () => {
226
+ const price = getAdmissionPriceDisplay({
227
+ admission: admission(),
228
+ initialPrice: {
229
+ discount: -1000,
230
+ gross: 2200,
231
+ total: 1200,
232
+ },
233
+ mode: "default",
234
+ });
235
+ expect(price).toEqual({
236
+ discounted: true,
237
+ gross: 2200,
238
+ total: 1200,
239
+ });
240
+ });
241
+ it("calculates admission savings from discounted display prices", () => {
242
+ expect(admissionPriceSavings({
243
+ discounted: true,
244
+ gross: 2200,
245
+ total: 1200,
246
+ })).toBe(1000);
247
+ expect(admissionPriceSavings({
248
+ discounted: false,
249
+ total: 2200,
250
+ })).toBe(0);
251
+ });
252
+ it("divides preview quote pricing by preview quantity", () => {
253
+ const price = getAdmissionPriceDisplay({
254
+ admission: admission(),
255
+ mode: "default",
256
+ previewItem: quoteItem({
257
+ quantity: 2,
258
+ price: {
259
+ base: 4000,
260
+ booking: 400,
261
+ currency: "AUD",
262
+ discount: -2000,
263
+ gross: 4400,
264
+ net: 2400,
265
+ refunded: 0,
266
+ total: 2400,
267
+ },
268
+ }),
269
+ });
270
+ expect(price).toEqual({
271
+ discounted: true,
272
+ gross: 2200,
273
+ total: 1200,
274
+ });
275
+ });
276
+ });