@session-services/web-elements 0.1.33 → 0.1.36

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 (26) hide show
  1. package/README.md +4 -3
  2. package/dist/cjs/loader.cjs.js +1 -1
  3. package/dist/cjs/session-services-elements.cjs.js +1 -1
  4. package/dist/cjs/session-services-ticketing.cjs.entry.js +374 -335
  5. package/dist/collection/components/session-services-ticketing/session-services-ticketing.js +82 -15
  6. package/dist/collection/components/session-services-ticketing/session-services-ticketing.logic.js +14 -375
  7. package/dist/collection/components/session-services-ticketing/session-services-ticketing.logic.test.js +5 -4
  8. package/dist/collection/components/session-services-ticketing/session-services-ticketing.test.js +23 -0
  9. package/dist/components/session-services-ticketing.js +1 -1
  10. package/dist/esm/loader.js +1 -1
  11. package/dist/esm/session-services-elements.js +1 -1
  12. package/dist/esm/session-services-ticketing.entry.js +374 -335
  13. package/dist/session-services-elements/p-2fa235a5.entry.js +1 -0
  14. package/dist/session-services-elements/session-services-elements.esm.js +1 -1
  15. package/dist/types/components/session-services-ticketing/session-services-ticketing.d.ts +10 -0
  16. package/dist/types/components/session-services-ticketing/session-services-ticketing.logic.d.ts +22 -190
  17. package/dist/types/components.d.ts +13 -0
  18. package/exports/components/formatted-text.js +1 -1
  19. package/exports/components/index.d.ts +4 -4
  20. package/exports/components/ticketing.js +1 -1
  21. package/exports/formatted-text.d.ts +3 -3
  22. package/exports/formatted-text.js +1 -1
  23. package/exports/ticketing.d.ts +5 -4
  24. package/exports/ticketing.js +1 -1
  25. package/package.json +16 -8
  26. package/dist/session-services-elements/p-7a1b9685.entry.js +0 -1
@@ -1,385 +1,24 @@
1
- import { isNaiveDateTimeFuture, isNaiveDateTimePast, parseNaiveDateTime, } from "@session-services/utils/naive-datetime";
2
- import { getAdmissionReleaseLabel } from "@session-services/utils/admission-release";
3
- import { getAdmissionSelectionCapacity, isAdmissionSoldOut, } from "@session-services/utils/ticket-availability";
4
- import { createQuoteItemMap } from "@session-services/utils/ticket-quote";
5
- const UNLIMITED_CAP = 10;
6
- const SCARCITY_THRESHOLD = 20;
7
- const DEFAULT_LOCALE = "en-US";
8
- export function selectionKey(admissionId, sectionId) {
9
- return `${admissionId}::${sectionId || "none"}`;
10
- }
11
- export function parseSelectionKey(key) {
12
- const [admissionId, sectionId] = key.split("::");
13
- return {
14
- admissionId,
15
- sectionId: sectionId === "none" ? undefined : sectionId,
16
- };
17
- }
18
- export function priceTotal(price) {
19
- if (!price)
20
- return 0;
21
- if ("total" in price && typeof price.total === "number")
22
- return price.total;
23
- return (price.base ?? 0) + (price.booking ?? 0);
24
- }
25
- export function discountCouponFromSearch(search) {
26
- return new URLSearchParams(search).get("discount") ?? "";
27
- }
28
- export function eventTimeZone(event) {
29
- return event.location?.timeZone ?? "UTC";
30
- }
31
- function eventEnded(event) {
32
- return isNaiveDateTimePast(event.entryEndsAt, eventTimeZone(event));
33
- }
34
- function sectionsById(event) {
35
- return new Map(event.sections.map((section) => [section.id, section]));
36
- }
37
- function hasAdmissionSection(admission, sections) {
38
- if (!admission.sectionId)
39
- return true;
40
- return sections.has(admission.sectionId);
41
- }
42
- function admissionSection(admission, sections) {
43
- if (!admission.sectionId)
44
- return undefined;
45
- return sections.get(admission.sectionId);
46
- }
47
- function isAdmissionOnSale(admission, zone) {
48
- if (isNaiveDateTimeFuture(admission.saleStartsAt, zone))
49
- return false;
50
- return !isNaiveDateTimePast(admission.saleEndsAt, zone);
51
- }
52
- function hasAdmissionCapacity(admission, section) {
53
- return getAdmissionSelectionCapacity({ admission, section }) > 0;
54
- }
55
- function isAdmissionPurchasable(admission, admissions, zone, sections) {
56
- if (!hasAdmissionSection(admission, sections))
57
- return false;
58
- if (getAdmissionReleaseLabel(admission, admissions, zone))
59
- return false;
60
- if (!isAdmissionOnSale(admission, zone))
61
- return false;
62
- return hasAdmissionCapacity(admission, admissionSection(admission, sections));
63
- }
64
- function hasPurchasableAdmissions(event) {
65
- const sections = sectionsById(event);
66
- const zone = eventTimeZone(event);
67
- return event.admissions.some((admission) => isAdmissionPurchasable(admission, event.admissions, zone, sections));
68
- }
69
- function admissionSaleLabel(admission, zone, locale) {
70
- if (!isNaiveDateTimeFuture(admission.saleStartsAt, zone)) {
71
- return null;
72
- }
73
- const date = parseNaiveDateTime(admission.saleStartsAt, "UTC");
74
- const day = dateFormatter(locale, {
75
- day: "numeric",
76
- month: "short",
77
- timeZone: "UTC",
78
- weekday: "short",
79
- }).format(date);
80
- const time = dateFormatter(locale, {
81
- hour: "numeric",
82
- minute: "2-digit",
83
- timeZone: "UTC",
84
- }).format(date);
85
- return {
86
- text: `On sale ${day} · ${time}`,
87
- tone: "muted",
88
- };
89
- }
90
- function dateFormatter(locale, options) {
91
- try {
92
- return new Intl.DateTimeFormat(locale, options);
93
- }
94
- catch {
95
- return new Intl.DateTimeFormat(DEFAULT_LOCALE, options);
96
- }
97
- }
98
- function admissionStatus(input) {
99
- if (input.value === "DOOR" && (input.soldOut || input.saleEnded)) {
100
- return { text: "Available At Door", tone: "success" };
101
- }
102
- if (input.soldOut)
103
- return { text: "Sold Out", tone: "destructive" };
104
- if (!input.saleEnded)
105
- return null;
106
- return { text: "Sale Ended", tone: "destructive" };
107
- }
108
- function endedStatus(admissions, zone) {
109
- const ended = admissions.filter((admission) => isNaiveDateTimePast(admission.saleEndsAt, zone));
110
- if (ended.length === 0)
111
- return undefined;
112
- if (ended.some((admission) => admission.saleEndsBehavior === "DOOR"))
113
- return "DOOR";
114
- if (ended.every((admission) => admission.saleEndsBehavior === "SOLD_OUT"))
115
- return "SOLD_OUT";
116
- return "SALE_ENDED";
117
- }
118
- function salesStatus(event) {
119
- const sections = sectionsById(event);
120
- const admissions = event.admissions.filter((admission) => hasAdmissionSection(admission, sections));
121
- if (admissions.length === 0)
122
- return undefined;
123
- if (hasPurchasableAdmissions(event))
124
- return "ONLINE";
125
- return endedStatus(admissions, eventTimeZone(event));
126
- }
127
- function hasDisplayableAdmissions(event) {
128
- const sections = sectionsById(event);
129
- return event.admissions.some((admission) => hasAdmissionSection(admission, sections));
130
- }
131
- function isDoorOnly(event) {
132
- if (hasPurchasableAdmissions(event))
133
- return false;
134
- return salesStatus(event) === "DOOR";
135
- }
136
- export function getTicketingState(event) {
137
- const ended = eventEnded(event);
138
- const externalTicketUrl = event.url || undefined;
139
- const doorOnly = isDoorOnly(event);
140
- const showExternalTicketing = !ended && Boolean(externalTicketUrl);
141
- const showTicketing = !ended && !showExternalTicketing && hasDisplayableAdmissions(event);
142
- return {
143
- externalTicketUrl,
144
- isDoorOnly: doorOnly,
145
- isEnded: ended,
146
- isUnavailableForSale: !doorOnly && !hasPurchasableAdmissions(event) && showTicketing,
147
- showExternalTicketing,
148
- showTicketing,
149
- ticketHeading: doorOnly ? "Tickets at the Door" : "Get Tickets",
150
- };
151
- }
1
+ import { discountCouponFromSearch, getTicketAdmissionDisplayState, getTicketAdmissionPriceDisplay, getTicketPreviewItems, getTicketSelectorState, getTopLevelTicketItems, getVisibleTicketAdmissions, parseTicketSelectionKey, quoteItemsByTicketSelection, ticketAdmissionPriceSavings, ticketEventTimeZone, ticketPriceTotal, ticketSelectionKey, } from "@session-services/utils/ticket-selector";
2
+ export const selectionKey = ticketSelectionKey;
3
+ export const parseSelectionKey = parseTicketSelectionKey;
4
+ export const priceTotal = ticketPriceTotal;
5
+ export { discountCouponFromSearch };
6
+ export const eventTimeZone = ticketEventTimeZone;
7
+ export const getTicketingState = getTicketSelectorState;
152
8
  export function visibleAdmissions(event, mode) {
153
- if (mode !== "door")
154
- return event.admissions;
155
- const zone = eventTimeZone(event);
156
- return event.admissions.filter((admission) => !getAdmissionReleaseLabel(admission, event.admissions, zone) &&
157
- admission.saleEndsBehavior === "DOOR" &&
158
- isNaiveDateTimePast(admission.saleEndsAt, zone));
159
- }
160
- function isStackableAdmission(input) {
161
- if (input.mode === "door")
162
- return true;
163
- if (!input.event)
164
- return true;
165
- return !getAdmissionDisplayState({
166
- admission: input.admission,
167
- event: input.event,
168
- section: input.section,
169
- }).disabled;
170
- }
171
- function stackedAdmissionBase(input) {
172
- const key = selectionKey(input.admission.id, input.section.id);
173
- return getAdmissionPriceDisplay({
174
- admission: input.admission,
175
- initialPrice: input.initialAdmissionPrices?.[key],
176
- mode: input.mode,
177
- previewItem: input.previewItems.get(key),
178
- quoteItem: input.quoteItems.get(key),
179
- }).base;
180
- }
181
- function compareStackedAdmissions(input, section) {
182
- const mode = input.mode ?? "default";
183
- const previewItems = quoteItemsBySelection(input.previewQuote ?? null);
184
- const quoteItems = quoteItemsBySelection(input.quote ?? null);
185
- return (a, b) => {
186
- const price = stackedAdmissionBase({
187
- admission: a,
188
- initialAdmissionPrices: input.initialAdmissionPrices,
189
- mode,
190
- previewItems,
191
- quoteItems,
192
- section,
193
- }) -
194
- stackedAdmissionBase({
195
- admission: b,
196
- initialAdmissionPrices: input.initialAdmissionPrices,
197
- mode,
198
- previewItems,
199
- quoteItems,
200
- section,
201
- });
202
- if (price !== 0)
203
- return price;
204
- const order = a.order - b.order;
205
- if (order !== 0)
206
- return order;
207
- return a.id.localeCompare(b.id);
208
- };
209
- }
210
- function cap(value) {
211
- if (value == null || value === -1)
212
- return Number.POSITIVE_INFINITY;
213
- return Math.max(0, value);
214
- }
215
- function shouldShowNextStackedAdmission(admission) {
216
- const remaining = cap(admission.available);
217
- if (!Number.isFinite(remaining))
218
- return false;
219
- const limit = cap(admission.limit);
220
- if (!Number.isFinite(limit))
221
- return remaining < UNLIMITED_CAP;
222
- return limit > remaining;
223
- }
224
- function displayedSectionAdmissions(section, admissions, input) {
225
- const sorted = admissions
226
- .filter((admission) => admission.sectionId === section.id)
227
- .sort((a, b) => a.order - b.order);
228
- if (section.visibility !== "STACKED" || sorted.length <= 1) {
229
- return sorted;
230
- }
231
- const mode = input.mode ?? "default";
232
- const stackable = sorted.filter((admission) => isStackableAdmission({
233
- admission,
234
- event: input.event,
235
- mode,
236
- section,
237
- }));
238
- const candidates = stackable.length > 0 ? stackable : sorted;
239
- const [admission, next] = [...candidates].sort(compareStackedAdmissions(input, section));
240
- if (!admission)
241
- return [];
242
- if (!next || !shouldShowNextStackedAdmission(admission)) {
243
- return [admission];
244
- }
245
- return [admission, next];
246
- }
247
- function sectionItems(sections, admissions, input) {
248
- return sections
249
- .map((section) => ({
250
- type: "section",
251
- section,
252
- admissions: displayedSectionAdmissions(section, admissions, input),
253
- }))
254
- .filter((item) => item.admissions.length > 0);
255
- }
256
- function topLevelOrder(item) {
257
- if (item.type === "section")
258
- return item.section.order;
259
- return item.admission.order;
9
+ return getVisibleTicketAdmissions(event, mode);
260
10
  }
261
11
  export function topLevelTicketItems(sections, admissions, input = {}) {
262
- return [
263
- ...sectionItems(sections, admissions, input),
264
- ...admissions
265
- .filter((admission) => !admission.sectionId)
266
- .map((admission) => ({ type: "admission", admission })),
267
- ].sort((a, b) => topLevelOrder(a) - topLevelOrder(b));
268
- }
269
- function isUnlimitedAdmission(admission, section) {
270
- if ((admission.available ?? 0) !== -1)
271
- return false;
272
- if (admission.capacity !== -1)
273
- return false;
274
- return !section || (section.available ?? 0) === -1;
275
- }
276
- function maxQuantity(admission, section) {
277
- if (isUnlimitedAdmission(admission, section))
278
- return UNLIMITED_CAP;
279
- return Math.min(getAdmissionSelectionCapacity({ admission, section }), UNLIMITED_CAP);
280
- }
281
- function showScarcity(admission, soldOut, disabled) {
282
- return [
283
- !soldOut,
284
- !disabled,
285
- (admission.available ?? 0) > 0,
286
- (admission.available ?? 0) < SCARCITY_THRESHOLD,
287
- ].every(Boolean);
12
+ return getTopLevelTicketItems(sections, admissions, input);
288
13
  }
289
14
  export function getAdmissionDisplayState(input) {
290
- const zone = eventTimeZone(input.event);
291
- const releaseLabel = getAdmissionReleaseLabel(input.admission, input.event.admissions, zone);
292
- const saleNotStarted = isNaiveDateTimeFuture(input.admission.saleStartsAt, zone);
293
- const saleEnded = isNaiveDateTimePast(input.admission.saleEndsAt, zone);
294
- const saleEndedSoldOut = saleEnded && input.admission.saleEndsBehavior === "SOLD_OUT";
295
- const soldOut = Boolean(input.sectionSoldOut) ||
296
- saleEndedSoldOut ||
297
- isAdmissionSoldOut({
298
- admission: input.admission,
299
- section: input.section,
300
- });
301
- const disabled = soldOut || saleNotStarted || saleEnded;
302
- const status = admissionStatus({
303
- saleEnded,
304
- soldOut,
305
- value: input.admission.saleEndsBehavior,
306
- });
307
- return {
308
- disabled: disabled || Boolean(releaseLabel),
309
- label: releaseLabel
310
- ? { ...releaseLabel, tone: "muted" }
311
- : admissionSaleLabel(input.admission, zone, input.locale ?? DEFAULT_LOCALE),
312
- maxQuantity: maxQuantity(input.admission, input.section),
313
- postSale: saleEnded,
314
- showScarcity: showScarcity(input.admission, soldOut, disabled),
315
- soldOut,
316
- statusLabel: status?.text ?? null,
317
- statusTone: status?.tone ?? null,
318
- };
319
- }
320
- function quoteItemUnits(item) {
321
- return Math.max(item?.quantity ?? 1, 1);
15
+ return getTicketAdmissionDisplayState(input);
322
16
  }
323
17
  export function getAdmissionPriceDisplay(input) {
324
- const item = input.quoteItem ?? input.previewItem;
325
- if (item) {
326
- const units = quoteItemUnits(item);
327
- const base = Math.round(Math.max((item.price.base + item.price.discount) / units, 0));
328
- const fees = Math.round(item.price.booking / units);
329
- if (!item.price.discount || item.price.discount >= 0) {
330
- return {
331
- base,
332
- discounted: false,
333
- fees,
334
- };
335
- }
336
- return {
337
- base,
338
- discounted: true,
339
- fees,
340
- gross: Math.round(item.price.base / units),
341
- };
342
- }
343
- if (input.initialPrice && input.initialPrice.discount < 0) {
344
- const fees = input.admission.price.booking ?? 0;
345
- return {
346
- base: Math.max(input.initialPrice.total - fees, 0),
347
- discounted: true,
348
- fees,
349
- gross: Math.max(input.initialPrice.gross - fees, 0),
350
- };
351
- }
352
- return {
353
- base: input.admission.price.base ?? 0,
354
- discounted: false,
355
- fees: input.admission.price.booking ?? 0,
356
- };
18
+ return getTicketAdmissionPriceDisplay(input);
357
19
  }
358
20
  export function quoteItemsBySelection(quote) {
359
- return createQuoteItemMap(quote);
360
- }
361
- export function admissionPriceSavings(price) {
362
- if (!price.discounted || price.gross === undefined)
363
- return 0;
364
- return Math.max(price.gross - price.base, 0);
365
- }
366
- export function previewQuoteItems(event) {
367
- const ids = new Set(event.sections.map((section) => section.id));
368
- const zone = eventTimeZone(event);
369
- return event.admissions
370
- .filter((admission) => {
371
- if (getAdmissionReleaseLabel(admission, event.admissions, zone)) {
372
- return false;
373
- }
374
- if (admission.sectionId && !ids.has(admission.sectionId))
375
- return false;
376
- if (isNaiveDateTimeFuture(admission.saleStartsAt, zone))
377
- return false;
378
- return !isNaiveDateTimePast(admission.saleEndsAt, zone);
379
- })
380
- .map((admission) => ({
381
- admissionId: admission.id ?? "",
382
- quantity: 1,
383
- sectionId: admission.sectionId ?? undefined,
384
- }));
21
+ return quoteItemsByTicketSelection(quote);
385
22
  }
23
+ export const admissionPriceSavings = ticketAdmissionPriceSavings;
24
+ export const previewQuoteItems = getTicketPreviewItems;
@@ -504,7 +504,7 @@ describe("session-services-ticketing logic", () => {
504
504
  gross: 2000,
505
505
  });
506
506
  });
507
- it("uses quote item base pricing without discount styling", () => {
507
+ it("uses the customer-inclusive quote price without discount styling", () => {
508
508
  const price = getAdmissionPriceDisplay({
509
509
  admission: admission(),
510
510
  mode: "default",
@@ -514,15 +514,16 @@ describe("session-services-ticketing logic", () => {
514
514
  booking: 200,
515
515
  currency: "AUD",
516
516
  discount: 0,
517
- gross: 2200,
518
- net: 2250,
517
+ gross: 2250,
518
+ net: 2050,
519
519
  refunded: 0,
520
+ tax: 50,
520
521
  total: 2250,
521
522
  },
522
523
  }),
523
524
  });
524
525
  expect(price).toEqual({
525
- base: 2000,
526
+ base: 2050,
526
527
  discounted: false,
527
528
  fees: 200,
528
529
  });
@@ -854,6 +854,29 @@ describe("session-services-ticketing component", () => {
854
854
  idempotencyKey,
855
855
  }));
856
856
  });
857
+ it("delegates only creation to a host backend and retains the attempt key on retry", async () => {
858
+ const item = widget();
859
+ item.selectedItems = new Map([[selectionKey("adm_general", null), 1]]);
860
+ item.client.order.quote = vi.fn().mockResolvedValue({ quote: quote() });
861
+ item.client.order.create = vi.fn();
862
+ const createOrder = vi
863
+ .fn()
864
+ .mockRejectedValue(new Error("Retry same purchase"));
865
+ item.createOrder = createOrder;
866
+ await item.updateQuote();
867
+ await item.initiateCheckout();
868
+ await item.initiateCheckout();
869
+ expect(item.client.order.quote).toHaveBeenCalled();
870
+ expect(item.client.order.create).not.toHaveBeenCalled();
871
+ expect(createOrder).toHaveBeenCalledTimes(2);
872
+ expect(createOrder.mock.calls[0]?.[0].idempotencyKey).toBe(createOrder.mock.calls[1]?.[0].idempotencyKey);
873
+ expect(createOrder.mock.calls[0]?.[0]).toMatchObject({
874
+ idempotencyKey: expect.any(String),
875
+ });
876
+ expect(createOrder.mock.calls[0]?.[1]).toMatchObject({
877
+ signal: expect.any(AbortSignal),
878
+ });
879
+ });
857
880
  it("blocks quantity changes while order creation is pending", async () => {
858
881
  const item = widget();
859
882
  const pending = deferred();