@session-services/web-elements 0.1.23 → 0.1.25

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.
@@ -42,6 +42,7 @@ function section(data = {}) {
42
42
  reserved: 0,
43
43
  limit: 10,
44
44
  order: 2,
45
+ visibility: "ALWAYS",
45
46
  teamId: "team_1",
46
47
  promoterId: "pro_1",
47
48
  eventId: "evt_1",
@@ -113,6 +114,25 @@ function quoteItem(data = {}) {
113
114
  ...data,
114
115
  };
115
116
  }
117
+ function quote(items) {
118
+ return {
119
+ items,
120
+ price: {
121
+ base: 0,
122
+ booking: 0,
123
+ currency: "AUD",
124
+ discount: 0,
125
+ dispute: 0,
126
+ gateway: 0,
127
+ gross: 0,
128
+ net: 0,
129
+ refunded: 0,
130
+ shipping: 0,
131
+ sms: 0,
132
+ total: 0,
133
+ },
134
+ };
135
+ }
116
136
  describe("session-services-ticketing logic", () => {
117
137
  it("reads discount coupon codes from query strings", () => {
118
138
  expect(discountCouponFromSearch("?discount=save20")).toBe("save20");
@@ -255,6 +275,169 @@ describe("session-services-ticketing logic", () => {
255
275
  const items = topLevelTicketItems([main], [sectioned, early]);
256
276
  expect(items.map((item) => item.type)).toEqual(["admission", "section"]);
257
277
  });
278
+ it("lists every visible admission in list sections", () => {
279
+ const main = section({ id: "sec_main", visibility: "ALWAYS" });
280
+ const first = admission({ id: "adm_first", order: 2, sectionId: main.id });
281
+ const second = admission({
282
+ id: "adm_second",
283
+ order: 1,
284
+ sectionId: main.id,
285
+ });
286
+ const [item] = topLevelTicketItems([main], [first, second]);
287
+ expect(item?.type).toBe("section");
288
+ expect(item?.type === "section" ? item.admissions.map((adm) => adm.id) : []).toEqual(["adm_second", "adm_first"]);
289
+ });
290
+ it("shows the cheapest base-price admission in stacked sections", () => {
291
+ const main = section({ visibility: "STACKED" });
292
+ const expensive = admission({
293
+ id: "adm_expensive",
294
+ order: 1,
295
+ price: { base: 3000, booking: 300, currency: "AUD" },
296
+ sectionId: main.id,
297
+ });
298
+ const cheap = admission({
299
+ id: "adm_cheap",
300
+ order: 2,
301
+ price: { base: 1500, booking: 150, currency: "AUD" },
302
+ sectionId: main.id,
303
+ });
304
+ const [item] = topLevelTicketItems([main], [expensive, cheap]);
305
+ expect(item?.type).toBe("section");
306
+ expect(item?.type === "section" ? item.admissions.map((adm) => adm.id) : []).toEqual(["adm_cheap"]);
307
+ });
308
+ it("skips sold-out admissions when stacking sections", () => {
309
+ const main = section({ visibility: "STACKED" });
310
+ const soldOut = admission({
311
+ id: "adm_sold_out",
312
+ available: 0,
313
+ capacity: 1,
314
+ order: 1,
315
+ price: { base: 1000, booking: 100, currency: "AUD" },
316
+ sectionId: main.id,
317
+ sold: 1,
318
+ });
319
+ const available = admission({
320
+ id: "adm_available",
321
+ order: 2,
322
+ price: { base: 2000, booking: 200, currency: "AUD" },
323
+ sectionId: main.id,
324
+ });
325
+ const data = event({
326
+ admissions: [soldOut, available],
327
+ sections: [main],
328
+ });
329
+ const [item] = topLevelTicketItems([main], data.admissions, {
330
+ event: data,
331
+ mode: "default",
332
+ });
333
+ expect(item?.type).toBe("section");
334
+ expect(item?.type === "section" ? item.admissions.map((adm) => adm.id) : []).toEqual(["adm_available"]);
335
+ });
336
+ it("shows the next stacked admission when the cheapest has fewer tickets than its per-order limit", () => {
337
+ const main = section({ visibility: "STACKED" });
338
+ const lowRemaining = admission({
339
+ id: "adm_low_remaining",
340
+ available: 3,
341
+ limit: 4,
342
+ price: { base: 1000, booking: 100, currency: "AUD" },
343
+ sectionId: main.id,
344
+ });
345
+ const next = admission({
346
+ id: "adm_next",
347
+ order: 2,
348
+ price: { base: 2000, booking: 200, currency: "AUD" },
349
+ sectionId: main.id,
350
+ });
351
+ const [item] = topLevelTicketItems([main], [lowRemaining, next]);
352
+ expect(item?.type).toBe("section");
353
+ expect(item?.type === "section" ? item.admissions.map((adm) => adm.id) : []).toEqual(["adm_low_remaining", "adm_next"]);
354
+ });
355
+ it("shows the next stacked admission when an unlimited per-order limit has fewer than ten tickets left", () => {
356
+ const main = section({ visibility: "STACKED" });
357
+ const lowRemaining = admission({
358
+ id: "adm_low_remaining",
359
+ available: 9,
360
+ limit: -1,
361
+ price: { base: 1000, booking: 100, currency: "AUD" },
362
+ sectionId: main.id,
363
+ });
364
+ const next = admission({
365
+ id: "adm_next",
366
+ order: 2,
367
+ price: { base: 2000, booking: 200, currency: "AUD" },
368
+ sectionId: main.id,
369
+ });
370
+ const [item] = topLevelTicketItems([main], [lowRemaining, next]);
371
+ expect(item?.type).toBe("section");
372
+ expect(item?.type === "section" ? item.admissions.map((adm) => adm.id) : []).toEqual(["adm_low_remaining", "adm_next"]);
373
+ });
374
+ it("keeps stacked admissions collapsed when an unlimited per-order limit has ten tickets left", () => {
375
+ const main = section({ visibility: "STACKED" });
376
+ const current = admission({
377
+ id: "adm_current",
378
+ available: 10,
379
+ limit: -1,
380
+ price: { base: 1000, booking: 100, currency: "AUD" },
381
+ sectionId: main.id,
382
+ });
383
+ const next = admission({
384
+ id: "adm_next",
385
+ order: 2,
386
+ price: { base: 2000, booking: 200, currency: "AUD" },
387
+ sectionId: main.id,
388
+ });
389
+ const [item] = topLevelTicketItems([main], [current, next]);
390
+ expect(item?.type).toBe("section");
391
+ expect(item?.type === "section" ? item.admissions.map((adm) => adm.id) : []).toEqual(["adm_current"]);
392
+ });
393
+ it("uses coupon-adjusted base prices when stacking sections", () => {
394
+ const main = section({ visibility: "STACKED" });
395
+ const general = admission({
396
+ id: "adm_general",
397
+ order: 1,
398
+ price: { base: 2000, booking: 200, currency: "AUD" },
399
+ sectionId: main.id,
400
+ });
401
+ const vip = admission({
402
+ id: "adm_vip",
403
+ order: 2,
404
+ price: { base: 3000, booking: 300, currency: "AUD" },
405
+ sectionId: main.id,
406
+ });
407
+ const [item] = topLevelTicketItems([main], [general, vip], {
408
+ mode: "default",
409
+ previewQuote: quote([
410
+ quoteItem({
411
+ admissionId: "adm_vip",
412
+ sectionId: main.id,
413
+ price: {
414
+ base: 3000,
415
+ booking: 300,
416
+ currency: "AUD",
417
+ discount: -2500,
418
+ gross: 3300,
419
+ net: 800,
420
+ refunded: 0,
421
+ total: 800,
422
+ },
423
+ }),
424
+ ]),
425
+ });
426
+ expect(item?.type).toBe("section");
427
+ expect(item?.type === "section" ? item.admissions.map((adm) => adm.id) : []).toEqual(["adm_vip"]);
428
+ });
429
+ it("does not stack unsectioned admissions", () => {
430
+ const main = section({ visibility: "STACKED", order: 2 });
431
+ const sectioned = admission({
432
+ id: "adm_sectioned",
433
+ order: 1,
434
+ sectionId: main.id,
435
+ });
436
+ const unsectioned = admission({ id: "adm_unsectioned", order: 1 });
437
+ const items = topLevelTicketItems([main], [sectioned, unsectioned]);
438
+ expect(items.map((item) => item.type)).toEqual(["admission", "section"]);
439
+ expect(items[0]?.type === "admission" ? items[0].admission.id : "").toBe("adm_unsectioned");
440
+ });
258
441
  it("caps unlimited admission quantity at the selector limit", () => {
259
442
  const data = event({
260
443
  admissions: [admission({ available: -1, capacity: -1 })],
@@ -138,6 +138,10 @@ function widget() {
138
138
  const item = new SessionsTicketing();
139
139
  item.eventId = "evt_1";
140
140
  item.event = event();
141
+ item.enableAnalytics = true;
142
+ item.host = {
143
+ getBoundingClientRect: () => ({ top: 0 }),
144
+ };
141
145
  item.selectedItems = new Map();
142
146
  item.quote = null;
143
147
  item.previewQuote = null;
@@ -194,6 +198,44 @@ describe("session-services-ticketing component", () => {
194
198
  expect(item.couponError).toBe("Failed to apply coupon. Please check the code.");
195
199
  expect(item.client.order.quote).toHaveBeenCalledWith(expect.objectContaining({ couponCode: "BAD" }));
196
200
  });
201
+ it("does not commit preview coupons when event reload fails", async () => {
202
+ const item = widget();
203
+ item.appliedCouponCode = "SAVE10";
204
+ item.couponInput = "bad";
205
+ item.loadEventData = vi.fn().mockRejectedValue(new Error("Down"));
206
+ await item.applyCoupon();
207
+ expect(item.appliedCouponCode).toBe("SAVE10");
208
+ expect(item.couponInput).toBe("BAD");
209
+ expect(item.couponError).toBe("Failed to apply coupon. Please check the code.");
210
+ });
211
+ it("does not apply stale cart coupons as preview coupons", async () => {
212
+ const item = widget();
213
+ const firstLoad = deferred();
214
+ const secondLoad = deferred();
215
+ item.couponInput = "old";
216
+ item.selectedItems = new Map([[selectionKey("adm_general", null), 1]]);
217
+ item.loadEventData = vi
218
+ .fn()
219
+ .mockReturnValueOnce(firstLoad.promise)
220
+ .mockReturnValueOnce(secondLoad.promise)
221
+ .mockResolvedValue(undefined);
222
+ item.client.order.quote = vi.fn().mockResolvedValue({ quote: quote() });
223
+ const stale = item.applyCoupon();
224
+ await Promise.resolve();
225
+ expect(item.appliedCouponCode).toBe("OLD");
226
+ item.couponInput = "new";
227
+ const latest = item.applyCoupon();
228
+ await Promise.resolve();
229
+ expect(item.appliedCouponCode).toBe("NEW");
230
+ item.selectedItems = new Map();
231
+ firstLoad.resolve();
232
+ await stale;
233
+ expect(item.appliedCouponCode).toBe("NEW");
234
+ expect(item.client.order.quote).not.toHaveBeenCalledWith(expect.objectContaining({ couponCode: "OLD" }));
235
+ secondLoad.resolve();
236
+ await latest;
237
+ expect(item.appliedCouponCode).toBe("NEW");
238
+ });
197
239
  it("keeps preview quote loading separate from cart quote loading", async () => {
198
240
  const item = widget();
199
241
  const next = deferred();
@@ -266,6 +308,18 @@ describe("session-services-ticketing component", () => {
266
308
  });
267
309
  expect(item.getCheckoutButtonText()).toBe("Continue");
268
310
  });
311
+ it("renders stable checkout e2e hooks", () => {
312
+ const item = widget();
313
+ const view = item.renderReadyState();
314
+ const cards = rendered(view, (node) => node.props["data-testid"] === "event-admission-card");
315
+ const inputs = rendered(view, (node) => node.props["data-testid"] === "event-admission-quantity-input");
316
+ const increments = rendered(view, (node) => node.props["data-testid"] === "event-admission-increment");
317
+ const checkout = rendered(view, (node) => node.props["data-testid"] === "event-checkout-cta-desktop");
318
+ expect(cards[0].props["data-admission-id"]).toBe("adm_general");
319
+ expect(inputs).toHaveLength(1);
320
+ expect(increments).toHaveLength(1);
321
+ expect(checkout).toHaveLength(1);
322
+ });
269
323
  it("renders external ticketing in the sticky checkout bar", () => {
270
324
  const item = widget();
271
325
  item.event = event({ url: "https://tickets.example" });
@@ -276,6 +330,9 @@ describe("session-services-ticketing component", () => {
276
330
  expect(links[0].props.href).toBe("https://tickets.example");
277
331
  expect(hasClass(links[0], "external-ticket-button")).toBe(true);
278
332
  expect(hasClass(links[0], "sticky-checkout-btn")).toBe(true);
333
+ expect(rendered(links[0], (node) => node.tag === "span" &&
334
+ hasClass(node, "external-ticket-domain") &&
335
+ node.children.join("") === "via tickets.example")).toHaveLength(1);
279
336
  });
280
337
  it("keeps external ticketing under the sticky checkout host state", () => {
281
338
  const item = widget();
@@ -290,6 +347,55 @@ describe("session-services-ticketing component", () => {
290
347
  expect(links.some((link) => hasClass(link, "inline-external-ticket-button"))).toBe(true);
291
348
  expect(links.some((link) => hasClass(link, "sticky-checkout-btn"))).toBe(true);
292
349
  });
350
+ it("tracks external ticket clicks by surface", () => {
351
+ const item = widget();
352
+ const track = vi.fn();
353
+ item.analytics = { track };
354
+ item.event = event({ url: "https://tickets.example/event" });
355
+ item.stickyCheckout = true;
356
+ const links = rendered(item.renderReadyState(), (node) => node.tag === "a");
357
+ const inline = links.find((link) => hasClass(link, "inline-external-ticket-button"));
358
+ const sticky = links.find((link) => hasClass(link, "sticky-checkout-btn"));
359
+ expect(typeof inline?.props.onClick).toBe("function");
360
+ expect(typeof sticky?.props.onClick).toBe("function");
361
+ (inline?.props.onClick)();
362
+ (sticky?.props.onClick)();
363
+ expect(track).toHaveBeenCalledWith("Checkout Started", expect.objectContaining({
364
+ event_id: "evt_1",
365
+ team_id: "team_1",
366
+ promoter_id: "pro_1",
367
+ ticketing_mode: "external",
368
+ external_ticket_url: "https://tickets.example/event",
369
+ external_ticket_domain: "tickets.example",
370
+ cta_surface: "inline",
371
+ }));
372
+ expect(track).toHaveBeenCalledWith("Checkout Started", expect.objectContaining({
373
+ ticketing_mode: "external",
374
+ external_ticket_url: "https://tickets.example/event",
375
+ external_ticket_domain: "tickets.example",
376
+ cta_surface: "sticky",
377
+ }));
378
+ });
379
+ it("scrolls to the ticket selector with a 100px top offset", () => {
380
+ const item = widget();
381
+ const scrollTo = vi.fn();
382
+ const tickets = {
383
+ getBoundingClientRect: () => ({ top: 420 }),
384
+ };
385
+ item.host = {
386
+ shadowRoot: {
387
+ querySelector: vi.fn().mockReturnValue(tickets),
388
+ },
389
+ getBoundingClientRect: () => ({ top: 900 }),
390
+ };
391
+ vi.stubGlobal("window", { scrollY: 250, scrollTo });
392
+ item.handleStickyCheckoutClick(getTicketingState(item.event));
393
+ expect(scrollTo).toHaveBeenCalledWith({
394
+ behavior: "smooth",
395
+ top: 570,
396
+ });
397
+ vi.unstubAllGlobals();
398
+ });
293
399
  it("classifies only passcode access failures as passcode errors", async () => {
294
400
  const item = widget();
295
401
  item.client.event.get = vi