@session-services/web-elements 0.1.30 → 0.1.31

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 (22) hide show
  1. package/dist/cjs/loader.cjs.js +1 -1
  2. package/dist/cjs/session-services-elements.cjs.js +1 -1
  3. package/dist/cjs/session-services-ticketing.cjs.entry.js +1645 -22994
  4. package/dist/collection/components/session-services-ticketing/bot-detection.js +83 -0
  5. package/dist/collection/components/session-services-ticketing/bot-detection.test.js +74 -0
  6. package/dist/collection/components/session-services-ticketing/session-services-ticketing.css +164 -36
  7. package/dist/collection/components/session-services-ticketing/session-services-ticketing.js +697 -219
  8. package/dist/collection/components/session-services-ticketing/session-services-ticketing.logic.js +25 -4
  9. package/dist/collection/components/session-services-ticketing/session-services-ticketing.logic.test.js +19 -2
  10. package/dist/collection/components/session-services-ticketing/session-services-ticketing.test.js +540 -19
  11. package/dist/components/session-services-ticketing.js +1 -1
  12. package/dist/esm/loader.js +1 -1
  13. package/dist/esm/session-services-elements.js +1 -1
  14. package/dist/esm/session-services-ticketing.entry.js +1645 -22994
  15. package/dist/session-services-elements/p-d7264f6b.entry.js +1 -0
  16. package/dist/session-services-elements/session-services-elements.esm.js +1 -1
  17. package/dist/types/components/session-services-ticketing/bot-detection.d.ts +13 -0
  18. package/dist/types/components/session-services-ticketing/bot-detection.test.d.ts +1 -0
  19. package/dist/types/components/session-services-ticketing/session-services-ticketing.d.ts +52 -5
  20. package/dist/types/components/session-services-ticketing/session-services-ticketing.logic.d.ts +8 -1
  21. package/package.json +4 -4
  22. package/dist/session-services-elements/p-4dd3f723.entry.js +0 -1
@@ -1,6 +1,18 @@
1
- import { describe, expect, it, vi } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { SessionsTicketing } from "./session-services-ticketing";
3
3
  import { getTicketingState, selectionKey, } from "./session-services-ticketing.logic";
4
+ const botDetection = vi.hoisted(() => ({
5
+ detectBot: vi.fn(() => ({ bot: false, signals: [] })),
6
+ isProductionApi: vi.fn((apiUrl) => apiUrl === "https://api.session.services"),
7
+ }));
8
+ const createClient = vi.hoisted(() => vi.fn(() => ({})));
9
+ const createAnalytics = vi.hoisted(() => vi.fn(() => ({ page: vi.fn(), track: vi.fn() })));
10
+ vi.mock("./bot-detection", () => botDetection);
11
+ vi.mock("@session-services/sdk", () => ({ createClient }));
12
+ vi.mock("@session-services/sdk/analytics", async (importOriginal) => ({
13
+ ...(await importOriginal()),
14
+ createAnalytics,
15
+ }));
4
16
  vi.mock("@stencil/core", () => ({
5
17
  Component: () => () => undefined,
6
18
  Element: () => () => undefined,
@@ -145,6 +157,7 @@ function hasClass(node, name) {
145
157
  }
146
158
  function widget() {
147
159
  const item = new SessionsTicketing();
160
+ item.apiUrl = "https://api.session.services";
148
161
  item.eventId = "evt_1";
149
162
  item.event = event();
150
163
  item.enableAnalytics = true;
@@ -155,17 +168,26 @@ function widget() {
155
168
  item.quote = null;
156
169
  item.previewQuote = null;
157
170
  item.quoteError = null;
171
+ item.checkoutError = null;
158
172
  item.couponError = null;
159
173
  item.appliedCouponCode = "";
160
174
  item.couponInput = "";
161
175
  item.couponCode = undefined;
162
176
  item.initialPreviewQuote = undefined;
163
177
  item.quoteLoading = false;
178
+ item.checkoutLoading = false;
164
179
  item.previewLoading = false;
165
180
  item.passcodeDenied = false;
166
181
  item.stickyCheckout = false;
182
+ item.tenantId = "tnt_1";
183
+ item.theme = "light";
167
184
  item.error = null;
185
+ item.errorKind = null;
186
+ item.initialLoading = false;
187
+ item.eventLoading = false;
168
188
  item.activePasscode = "";
189
+ item.passcodeInput = "";
190
+ item.passcodeLoading = false;
169
191
  item.cartUpdate = { emit: vi.fn() };
170
192
  item.checkoutInit = { emit: vi.fn() };
171
193
  item.errorOccurred = { emit: vi.fn() };
@@ -182,6 +204,91 @@ function widget() {
182
204
  return item;
183
205
  }
184
206
  describe("session-services-ticketing component", () => {
207
+ beforeEach(() => {
208
+ botDetection.detectBot.mockReset();
209
+ botDetection.detectBot.mockReturnValue({ bot: false, signals: [] });
210
+ botDetection.isProductionApi.mockReset();
211
+ botDetection.isProductionApi.mockImplementation((apiUrl) => apiUrl === "https://api.session.services");
212
+ createClient.mockReset();
213
+ createClient.mockReturnValue({});
214
+ createAnalytics.mockReset();
215
+ createAnalytics.mockReturnValue({ page: vi.fn(), track: vi.fn() });
216
+ });
217
+ afterEach(() => {
218
+ vi.useRealTimers();
219
+ vi.unstubAllGlobals();
220
+ });
221
+ it("defers production API loading when automation is detected", async () => {
222
+ const item = widget();
223
+ const loadEventData = vi
224
+ .fn()
225
+ .mockResolvedValue({ status: "success", event: event() });
226
+ item.event = null;
227
+ item.loadEventData = loadEventData;
228
+ botDetection.detectBot.mockReturnValue({
229
+ bot: true,
230
+ signals: ["webdriver"],
231
+ });
232
+ await item.initializeComponent();
233
+ expect(createClient).not.toHaveBeenCalled();
234
+ expect(loadEventData).not.toHaveBeenCalled();
235
+ expect(item.verificationRequired).toBe(true);
236
+ expect(textContent(item.pendingView())).toContain("Let’s get your tickets");
237
+ expect(textContent(item.pendingView())).toContain("We just need you to confirm before we load the ticket options");
238
+ expect(textContent(item.pendingView())).toContain("Load tickets");
239
+ expect(hasClass(rendered(item.pendingView(), (node) => node.tag === "button")[0], "checkout-btn")).toBe(true);
240
+ });
241
+ it("loads the normal flow after the user continues", async () => {
242
+ const item = widget();
243
+ const loadEventData = vi
244
+ .fn()
245
+ .mockResolvedValue({ status: "success", event: event() });
246
+ item.event = null;
247
+ item.loadEventData = loadEventData;
248
+ botDetection.detectBot.mockReturnValue({
249
+ bot: true,
250
+ signals: ["webdriver"],
251
+ });
252
+ await item.initializeComponent();
253
+ const button = rendered(item.pendingView(), (node) => node.tag === "button")[0];
254
+ await button.props.onClick();
255
+ expect(botDetection.detectBot).toHaveBeenCalledOnce();
256
+ expect(createClient).toHaveBeenCalledWith({
257
+ environment: "https://api.session.services",
258
+ tenantId: "tnt_1",
259
+ });
260
+ expect(loadEventData).toHaveBeenCalledOnce();
261
+ expect(item.verificationRequired).toBe(false);
262
+ await item.initializeComponent();
263
+ expect(botDetection.detectBot).toHaveBeenCalledOnce();
264
+ expect(loadEventData).toHaveBeenCalledTimes(2);
265
+ });
266
+ it("bypasses detection outside the production API", async () => {
267
+ const item = widget();
268
+ const loadEventData = vi
269
+ .fn()
270
+ .mockResolvedValue({ status: "success", event: event() });
271
+ item.apiUrl = "https://j-api.dev.session.services";
272
+ item.event = null;
273
+ item.loadEventData = loadEventData;
274
+ await item.initializeComponent();
275
+ expect(botDetection.detectBot).not.toHaveBeenCalled();
276
+ expect(createClient).toHaveBeenCalledOnce();
277
+ expect(loadEventData).toHaveBeenCalledOnce();
278
+ expect(item.verificationRequired).toBe(false);
279
+ });
280
+ it("uses the checkout action style for recoverable load errors", () => {
281
+ const item = widget();
282
+ item.error = "Failed to load event data";
283
+ item.event = null;
284
+ item.loading = false;
285
+ const view = item.pendingView();
286
+ const button = rendered(view, (node) => node.tag === "button")[0];
287
+ expect(textContent(view)).toContain("Tickets didn’t load");
288
+ expect(textContent(view)).toContain("Something got in the way. Try again to load the ticket options.");
289
+ expect(textContent(button)).toBe("Try again");
290
+ expect(hasClass(button, "checkout-btn")).toBe(true);
291
+ });
185
292
  it("clears stale cart quotes when requoting fails", async () => {
186
293
  const item = widget();
187
294
  item.quote = quote();
@@ -192,6 +299,24 @@ describe("session-services-ticketing component", () => {
192
299
  expect(item.quoteError).toBe("Failed to update cart. Please try again.");
193
300
  expect(item.isCheckoutDisabled()).toBe(true);
194
301
  });
302
+ it("offers an explicit retry when a cart quote fails", async () => {
303
+ const item = widget();
304
+ item.selectedItems = new Map([[selectionKey("adm_general", null), 1]]);
305
+ item.client.event.get = vi.fn().mockResolvedValue({
306
+ event: item.event,
307
+ });
308
+ item.client.order.quote = vi
309
+ .fn()
310
+ .mockRejectedValueOnce(new Error("Down"))
311
+ .mockResolvedValueOnce({ quote: quote() });
312
+ await item.updateQuote();
313
+ const view = item.renderReadyState();
314
+ const retry = rendered(view, (node) => node.tag === "button" && textContent(node) === "Try again");
315
+ expect(retry).toHaveLength(1);
316
+ await retry[0].props.onClick();
317
+ expect(item.quote).not.toBeNull();
318
+ expect(item.quoteError).toBeNull();
319
+ });
195
320
  it("does not commit failed cart coupon applications", async () => {
196
321
  const item = widget();
197
322
  const current = quote();
@@ -199,15 +324,18 @@ describe("session-services-ticketing component", () => {
199
324
  item.appliedCouponCode = "SAVE10";
200
325
  item.couponInput = "bad";
201
326
  item.selectedItems = new Map([[selectionKey("adm_general", null), 1]]);
327
+ item.client.event.get = vi.fn().mockResolvedValue({
328
+ event: item.event,
329
+ });
202
330
  item.client.order.quote = vi
203
331
  .fn()
204
332
  .mockRejectedValue(new Error("Invalid coupon"));
205
333
  await item.applyCoupon();
206
334
  expect(item.appliedCouponCode).toBe("SAVE10");
207
- expect(item.couponInput).toBe("BAD");
335
+ expect(item.couponInput).toBe("SAVE10");
208
336
  expect(item.quote).toBe(current);
209
337
  expect(item.couponError).toBe("Failed to apply coupon. Please check the code.");
210
- expect(item.client.order.quote).toHaveBeenCalledWith(expect.objectContaining({ couponCode: "BAD" }));
338
+ expect(vi.mocked(item.client.order.quote).mock.calls[0][0]).toEqual(expect.objectContaining({ couponCode: "BAD" }));
211
339
  });
212
340
  it("does not commit preview coupons when event reload fails", async () => {
213
341
  const item = widget();
@@ -216,7 +344,7 @@ describe("session-services-ticketing component", () => {
216
344
  item.loadEventData = vi.fn().mockRejectedValue(new Error("Down"));
217
345
  await item.applyCoupon();
218
346
  expect(item.appliedCouponCode).toBe("SAVE10");
219
- expect(item.couponInput).toBe("BAD");
347
+ expect(item.couponInput).toBe("SAVE10");
220
348
  expect(item.couponError).toBe("Failed to apply coupon. Please check the code.");
221
349
  });
222
350
  it("does not apply stale cart coupons as preview coupons", async () => {
@@ -229,24 +357,61 @@ describe("session-services-ticketing component", () => {
229
357
  .fn()
230
358
  .mockReturnValueOnce(firstLoad.promise)
231
359
  .mockReturnValueOnce(secondLoad.promise)
232
- .mockResolvedValue(undefined);
360
+ .mockResolvedValue({
361
+ status: "success",
362
+ event: item.event,
363
+ });
233
364
  item.client.order.quote = vi.fn().mockResolvedValue({ quote: quote() });
234
365
  const stale = item.applyCoupon();
235
366
  await Promise.resolve();
236
- expect(item.appliedCouponCode).toBe("OLD");
367
+ expect(item.appliedCouponCode).toBe("");
237
368
  item.couponInput = "new";
238
369
  const latest = item.applyCoupon();
239
370
  await Promise.resolve();
240
- expect(item.appliedCouponCode).toBe("NEW");
241
- item.selectedItems = new Map();
242
- firstLoad.resolve();
371
+ expect(item.appliedCouponCode).toBe("");
372
+ firstLoad.resolve({
373
+ status: "success",
374
+ event: item.event,
375
+ });
243
376
  await stale;
244
- expect(item.appliedCouponCode).toBe("NEW");
245
- expect(item.client.order.quote).not.toHaveBeenCalledWith(expect.objectContaining({ couponCode: "OLD" }));
246
- secondLoad.resolve();
377
+ expect(item.appliedCouponCode).toBe("");
378
+ expect(vi
379
+ .mocked(item.client.order.quote)
380
+ .mock.calls.every(([input]) => input.couponCode !== "OLD")).toBe(true);
381
+ secondLoad.resolve({
382
+ status: "success",
383
+ event: item.event,
384
+ });
247
385
  await latest;
248
386
  expect(item.appliedCouponCode).toBe("NEW");
249
387
  });
388
+ it("fetches exactly one cart quote when applying a coupon", async () => {
389
+ const item = widget();
390
+ item.couponInput = "save10";
391
+ item.selectedItems = new Map([[selectionKey("adm_general", null), 2]]);
392
+ item.client.event.get = vi.fn().mockResolvedValue({
393
+ event: item.event,
394
+ });
395
+ item.client.order.quote = vi.fn().mockResolvedValue({ quote: quote() });
396
+ await item.applyCoupon();
397
+ expect(item.client.order.quote).toHaveBeenCalledOnce();
398
+ expect(vi.mocked(item.client.order.quote).mock.calls[0][0]).toEqual(expect.objectContaining({
399
+ couponCode: "SAVE10",
400
+ items: [expect.objectContaining({ quantity: 2 })],
401
+ }));
402
+ });
403
+ it("fetches exactly one preview quote when applying a coupon", async () => {
404
+ const item = widget();
405
+ item.couponInput = "save10";
406
+ item.selectedItems = new Map();
407
+ item.client.event.get = vi.fn().mockResolvedValue({
408
+ event: item.event,
409
+ });
410
+ item.client.order.quote = vi.fn().mockResolvedValue({ quote: quote() });
411
+ await item.applyCoupon();
412
+ expect(item.client.order.quote).toHaveBeenCalledOnce();
413
+ expect(vi.mocked(item.client.order.quote).mock.calls[0][0]).toEqual(expect.objectContaining({ couponCode: "SAVE10" }));
414
+ });
250
415
  it("keeps preview quote loading separate from cart quote loading", async () => {
251
416
  const item = widget();
252
417
  const next = deferred();
@@ -331,6 +496,12 @@ describe("session-services-ticketing component", () => {
331
496
  expect(increments).toHaveLength(1);
332
497
  expect(checkout).toHaveLength(1);
333
498
  });
499
+ it("does not add a redundant tab stop to admission cards", () => {
500
+ const item = widget();
501
+ const cards = rendered(item.renderReadyState(), (node) => node.props["data-testid"] === "event-admission-card");
502
+ expect(cards).toHaveLength(1);
503
+ expect(cards[0].props.tabindex).toBeUndefined();
504
+ });
334
505
  it("renders external ticketing in the sticky checkout bar", () => {
335
506
  const item = widget();
336
507
  item.event = event({ url: "https://tickets.example" });
@@ -367,10 +538,16 @@ describe("session-services-ticketing component", () => {
367
538
  const links = rendered(item.renderReadyState(), (node) => node.tag === "a");
368
539
  const inline = links.find((link) => hasClass(link, "inline-external-ticket-button"));
369
540
  const sticky = links.find((link) => hasClass(link, "sticky-checkout-btn"));
370
- expect(typeof inline?.props.onClick).toBe("function");
371
- expect(typeof sticky?.props.onClick).toBe("function");
372
- (inline?.props.onClick)();
373
- (sticky?.props.onClick)();
541
+ const inlineClick = inline?.props.onClick;
542
+ const stickyClick = sticky?.props.onClick;
543
+ expect(typeof inlineClick).toBe("function");
544
+ expect(typeof stickyClick).toBe("function");
545
+ if (typeof inlineClick !== "function" ||
546
+ typeof stickyClick !== "function") {
547
+ throw new Error("Expected both external ticket links to be clickable");
548
+ }
549
+ inlineClick();
550
+ stickyClick();
374
551
  expect(track).toHaveBeenCalledWith("Checkout Started", expect.objectContaining({
375
552
  event_id: "evt_1",
376
553
  team_id: "team_1",
@@ -403,7 +580,9 @@ describe("session-services-ticketing component", () => {
403
580
  expect(track).toHaveBeenCalledWith("Product Viewed", expect.objectContaining({
404
581
  event_id: "evt_1",
405
582
  product_id: "evt_1",
583
+ value: 22,
406
584
  price: 22,
585
+ currency: "AUD",
407
586
  }));
408
587
  expect(track).toHaveBeenCalledWith("Product List Viewed", expect.objectContaining({
409
588
  list_id: "evt_1",
@@ -411,11 +590,45 @@ describe("session-services-ticketing component", () => {
411
590
  expect.objectContaining({
412
591
  admission_id: "adm_general",
413
592
  price: 22,
593
+ price_base: 2000,
594
+ price_booking_fee: 200,
414
595
  }),
415
596
  ],
416
597
  }));
417
598
  expect(item.analyticsEventSkipped.emit).not.toHaveBeenCalled();
418
599
  });
600
+ it("uses currency minor units for ticketing widget analytics", () => {
601
+ const item = widget();
602
+ const track = vi.fn();
603
+ item.analytics = { page: vi.fn(), track };
604
+ item.event = event({
605
+ currency: "JPY",
606
+ admissions: [
607
+ admission({
608
+ price: {
609
+ base: 2200,
610
+ booking: 0,
611
+ currency: "JPY",
612
+ },
613
+ }),
614
+ ],
615
+ });
616
+ item.trackLoadedEvent(item.event);
617
+ expect(track).toHaveBeenCalledWith("Product Viewed", expect.objectContaining({
618
+ value: 2200,
619
+ price: 2200,
620
+ currency: "JPY",
621
+ }));
622
+ expect(track).toHaveBeenCalledWith("Product List Viewed", expect.objectContaining({
623
+ products: [
624
+ expect.objectContaining({
625
+ price: 2200,
626
+ price_base: 2200,
627
+ price_booking_fee: 0,
628
+ }),
629
+ ],
630
+ }));
631
+ });
419
632
  it("emits an analytics diagnostic when event identity is incomplete", () => {
420
633
  const item = widget();
421
634
  const page = vi.fn();
@@ -438,18 +651,29 @@ describe("session-services-ticketing component", () => {
438
651
  const item = widget();
439
652
  const track = vi.fn();
440
653
  item.analytics = { track };
441
- item.quote = quote();
442
654
  item.selectedItems = new Map([[selectionKey("adm_general", null), 1]]);
655
+ item.client.order.quote = vi.fn().mockResolvedValue({ quote: quote() });
443
656
  item.client.order.create = vi.fn().mockResolvedValue({
444
657
  order: {
445
658
  checkoutUrl: "https://checkout.example/ord_1",
446
659
  id: "ord_1",
447
660
  },
448
661
  });
662
+ await item.updateQuote();
449
663
  await item.initiateCheckout();
450
664
  expect(track).toHaveBeenCalledWith("Checkout Started", expect.objectContaining({
451
665
  order_id: "ord_1",
452
666
  value: 22,
667
+ revenue: 22,
668
+ currency: "AUD",
669
+ products: [
670
+ expect.objectContaining({
671
+ admission_id: "adm_general",
672
+ price: 22,
673
+ price_base: 2000,
674
+ price_booking_fee: 200,
675
+ }),
676
+ ],
453
677
  }));
454
678
  });
455
679
  it("scrolls to the ticket selector with a 100px top offset", () => {
@@ -464,13 +688,16 @@ describe("session-services-ticketing component", () => {
464
688
  },
465
689
  getBoundingClientRect: () => ({ top: 900 }),
466
690
  };
467
- vi.stubGlobal("window", { scrollY: 250, scrollTo });
691
+ vi.stubGlobal("window", {
692
+ matchMedia: vi.fn().mockReturnValue({ matches: false }),
693
+ scrollY: 250,
694
+ scrollTo,
695
+ });
468
696
  item.handleStickyCheckoutClick(getTicketingState(event()));
469
697
  expect(scrollTo).toHaveBeenCalledWith({
470
698
  behavior: "smooth",
471
699
  top: 570,
472
700
  });
473
- vi.unstubAllGlobals();
474
701
  });
475
702
  it("shows only the placeholder name for secret event locations", () => {
476
703
  const item = widget();
@@ -500,4 +727,298 @@ describe("session-services-ticketing component", () => {
500
727
  await item.loadEventData();
501
728
  expect(item.passcodeDenied).toBe(true);
502
729
  });
730
+ it("starts initial loading without delaying the first render", () => {
731
+ const item = widget();
732
+ const initialize = vi.fn().mockResolvedValue(undefined);
733
+ item.event = null;
734
+ item.initialLoading = true;
735
+ item.loading = true;
736
+ item.initializeComponent = initialize;
737
+ expect(item.componentWillLoad()).toBeUndefined();
738
+ expect(initialize).toHaveBeenCalledOnce();
739
+ const view = item.pendingView();
740
+ const status = rendered(view, (node) => node.props.role === "status");
741
+ expect(status).toHaveLength(1);
742
+ expect(status[0].props["aria-live"]).toBe("polite");
743
+ expect(rendered(view, (node) => hasClass(node, "loading-admission"))).toHaveLength(3);
744
+ });
745
+ it("keeps ticket content mounted during a background event refresh", () => {
746
+ const item = widget();
747
+ item.initialLoading = false;
748
+ item.loading = false;
749
+ item.eventLoading = true;
750
+ const view = item.render();
751
+ expect(rendered(view, (node) => node.props["data-testid"] === "event-admission-card")).toHaveLength(1);
752
+ expect(rendered(view, (node) => hasClass(node, "loading-admission"))).toHaveLength(0);
753
+ expect(rendered(view, (node) => node.props["aria-busy"] === "true")).not.toHaveLength(0);
754
+ });
755
+ it("ignores an event response after a newer event load wins", async () => {
756
+ const item = widget();
757
+ const first = deferred();
758
+ const second = deferred();
759
+ const firstEvent = event({ id: "evt_first", slug: "first" });
760
+ const secondEvent = event({ id: "evt_second", slug: "second" });
761
+ item.event = null;
762
+ item.initialLoading = false;
763
+ item.loading = false;
764
+ item.client.event.get = vi
765
+ .fn()
766
+ .mockReturnValueOnce(first.promise)
767
+ .mockReturnValueOnce(second.promise);
768
+ item.eventId = firstEvent.id;
769
+ const stale = item.loadEventData();
770
+ item.eventId = secondEvent.id;
771
+ const latest = item.loadEventData();
772
+ second.resolve({ event: secondEvent });
773
+ await latest;
774
+ first.resolve({ event: firstEvent });
775
+ await stale;
776
+ expect(item.event?.id).toBe(secondEvent.id);
777
+ expect(item.eventId).toBe(secondEvent.id);
778
+ });
779
+ it("does not commit an event response after disconnection", async () => {
780
+ const item = widget();
781
+ const response = deferred();
782
+ item.event = null;
783
+ item.initialLoading = false;
784
+ item.loading = false;
785
+ item.client.event.get = vi.fn().mockReturnValue(response.promise);
786
+ vi.stubGlobal("document", { removeEventListener: vi.fn() });
787
+ const pending = item.loadEventData();
788
+ item.disconnectedCallback();
789
+ response.resolve({ event: event() });
790
+ await pending;
791
+ expect(item.event).toBeNull();
792
+ vi.unstubAllGlobals();
793
+ });
794
+ it("invalidates an active quote as soon as its selection changes", async () => {
795
+ vi.useFakeTimers();
796
+ const item = widget();
797
+ const stale = deferred();
798
+ item.selectedItems = new Map([[selectionKey("adm_general", null), 1]]);
799
+ item.client.order.quote = vi.fn().mockReturnValue(stale.promise);
800
+ const pending = item.updateQuote();
801
+ await Promise.resolve();
802
+ await item.updateQuantity("adm_general", null, -1);
803
+ stale.resolve({ quote: quote() });
804
+ await pending;
805
+ expect(item.selectedItems.size).toBe(0);
806
+ expect(item.quote).toBeNull();
807
+ expect(item.isCheckoutDisabled()).toBe(true);
808
+ vi.clearAllTimers();
809
+ vi.useRealTimers();
810
+ });
811
+ it("rolls back a coupon removal when the replacement quote fails", async () => {
812
+ const item = widget();
813
+ const currentEvent = item.event;
814
+ const currentQuote = quote();
815
+ item.appliedCouponCode = "SAVE10";
816
+ item.couponInput = "SAVE10";
817
+ item.quote = currentQuote;
818
+ item.selectedItems = new Map([[selectionKey("adm_general", null), 1]]);
819
+ item.client.event.get = vi.fn().mockResolvedValue({ event: currentEvent });
820
+ item.client.order.quote = vi.fn().mockRejectedValue(new Error("Down"));
821
+ await item.removeCoupon();
822
+ expect(item.appliedCouponCode).toBe("SAVE10");
823
+ expect(item.couponInput).toBe("SAVE10");
824
+ expect(item.event).toBe(currentEvent);
825
+ expect(item.quote).toBe(currentQuote);
826
+ expect(item.couponError).toContain("remove coupon");
827
+ });
828
+ it("keeps a valid quote retryable after checkout creation fails", async () => {
829
+ const item = widget();
830
+ item.selectedItems = new Map([[selectionKey("adm_general", null), 1]]);
831
+ item.client.order.quote = vi.fn().mockResolvedValue({ quote: quote() });
832
+ item.client.order.create = vi.fn().mockRejectedValue(new Error("Down"));
833
+ await item.updateQuote();
834
+ await item.initiateCheckout();
835
+ await item.initiateCheckout();
836
+ expect(item.quote).not.toBeNull();
837
+ expect(item.quoteError).toBeNull();
838
+ expect(item.checkoutError?.toLowerCase()).toContain("checkout");
839
+ expect(item.isCheckoutDisabled()).toBe(false);
840
+ const checkout = rendered(item.renderReadyState(), (node) => node.props["data-testid"] === "event-checkout-cta-desktop")[0];
841
+ expect(checkout.props.disabled).toBe(false);
842
+ const calls = vi.mocked(item.client.order.create).mock.calls;
843
+ expect(calls).toHaveLength(2);
844
+ const firstRequest = calls[0]?.[0];
845
+ if (typeof firstRequest !== "object" ||
846
+ firstRequest === null ||
847
+ !("idempotencyKey" in firstRequest) ||
848
+ typeof firstRequest.idempotencyKey !== "string") {
849
+ throw new Error("Expected the first checkout request to have a key");
850
+ }
851
+ const idempotencyKey = firstRequest.idempotencyKey;
852
+ expect(calls[0]?.[0]).toEqual(expect.objectContaining({ idempotencyKey: expect.any(String) }));
853
+ expect(calls[1]?.[0]).toEqual(expect.objectContaining({
854
+ idempotencyKey,
855
+ }));
856
+ });
857
+ it("blocks quantity changes while order creation is pending", async () => {
858
+ const item = widget();
859
+ const pending = deferred();
860
+ item.selectedItems = new Map([[selectionKey("adm_general", null), 1]]);
861
+ item.client.order.quote = vi.fn().mockResolvedValue({ quote: quote() });
862
+ item.client.order.create = vi.fn().mockReturnValue(pending.promise);
863
+ await item.updateQuote();
864
+ const checkout = item.initiateCheckout();
865
+ await vi.waitFor(() => {
866
+ expect(item.client.order.create).toHaveBeenCalledOnce();
867
+ });
868
+ await item.updateQuantity("adm_general", null, 1);
869
+ expect(item.selectedItems.get(selectionKey("adm_general", null))).toBe(1);
870
+ pending.resolve({ order: { id: "ord_1" } });
871
+ await checkout;
872
+ });
873
+ it("closes the modal when a refreshed event removes its admission", () => {
874
+ const item = widget();
875
+ item.openModalAdmissionId = "adm_removed";
876
+ item.pruneSelectedItems(event());
877
+ expect(item.openModalAdmissionId).toBeNull();
878
+ });
879
+ it("keeps refresh feedback visible when its retry fails", async () => {
880
+ const item = widget();
881
+ item.client.event.get = vi.fn().mockRejectedValue(new Error("Still down"));
882
+ await item.retryEventLoad();
883
+ expect(item.refreshError).toBe("Still down");
884
+ expect(item.error).toBeNull();
885
+ });
886
+ it("shows the applied coupon consistently until it is removed", () => {
887
+ const item = widget();
888
+ item.appliedCouponCode = "SAVE10";
889
+ item.couponInput = "BAD";
890
+ const input = rendered(item.renderReadyState(), (node) => node.tag === "input" && node.props["aria-label"] === "Coupon code")[0];
891
+ expect(input.props.value).toBe("SAVE10");
892
+ expect(input.props.disabled || input.props.readonly).toBe(true);
893
+ });
894
+ it("renders configuration failures without an unusable retry action", async () => {
895
+ const item = widget();
896
+ item.event = null;
897
+ item.tenantId = "";
898
+ await item.initializeComponent();
899
+ const view = item.pendingView();
900
+ expect(item.errorKind).toBe("configuration");
901
+ expect(textContent(view).toLowerCase()).toMatch(/configur|set ?up/);
902
+ expect(rendered(view, (node) => node.tag === "button")).toHaveLength(0);
903
+ });
904
+ it("renders not found separately from retryable load failures", async () => {
905
+ const item = widget();
906
+ item.event = null;
907
+ item.initialLoading = false;
908
+ item.loading = false;
909
+ item.client.event.get = vi.fn().mockRejectedValue(Object.assign(new Error("Missing"), {
910
+ code: "NOT_FOUND",
911
+ }));
912
+ await item.loadEventData();
913
+ const view = item.pendingView();
914
+ expect(item.errorKind).toBe("not_found");
915
+ expect(textContent(view)).toContain("Event not found");
916
+ expect(rendered(view, (node) => node.tag === "button")).toHaveLength(0);
917
+ });
918
+ it("announces transient load failures and keeps retry available", async () => {
919
+ const item = widget();
920
+ item.event = null;
921
+ item.initialLoading = false;
922
+ item.loading = false;
923
+ item.client.event.get = vi.fn().mockRejectedValue(new Error("Down"));
924
+ await item.loadEventData();
925
+ const view = item.pendingView();
926
+ const alert = rendered(view, (node) => node.props.role === "alert");
927
+ const retry = rendered(view, (node) => node.tag === "button" && textContent(node) === "Try again");
928
+ expect(item.errorKind).toBe("transient");
929
+ expect(alert).toHaveLength(1);
930
+ expect(alert[0].props["aria-live"]).toBe("assertive");
931
+ expect(retry).toHaveLength(1);
932
+ expect(hasClass(retry[0], "checkout-btn")).toBe(true);
933
+ });
934
+ it("keeps passcode feedback associated with the mounted form", () => {
935
+ const item = widget();
936
+ item.event = null;
937
+ item.initialLoading = false;
938
+ item.loading = false;
939
+ item.error = "Incorrect passcode";
940
+ item.errorKind = "access";
941
+ item.passcodeDenied = true;
942
+ item.activePasscode = "wrong";
943
+ item.passcodeInput = "wrong";
944
+ item.passcodeLoading = true;
945
+ const view = item.pendingView();
946
+ const input = rendered(view, (node) => node.tag === "input" && node.props.name === "passcode")[0];
947
+ const button = rendered(view, (node) => node.tag === "button" && node.props.type === "submit")[0];
948
+ const feedback = rendered(view, (node) => node.props.id === input.props["aria-describedby"])[0];
949
+ expect(input.props["aria-invalid"]).toBe(true);
950
+ expect(input.props["aria-describedby"]).toBeTruthy();
951
+ expect(feedback.props.role).toBe("alert");
952
+ expect(textContent(button)).toContain("Unlocking");
953
+ });
954
+ it("describes ticket detail dialogs with their visible content", () => {
955
+ const item = widget();
956
+ item.event = event({
957
+ admissions: [admission({ description: "Includes balcony access." })],
958
+ });
959
+ item.openModalAdmissionId = "adm_general";
960
+ const view = item.renderReadyState();
961
+ const dialog = rendered(view, (node) => node.props.role === "dialog")[0];
962
+ const description = rendered(view, (node) => node.props.id === dialog.props["aria-describedby"])[0];
963
+ expect(dialog.props["aria-modal"]).toBe("true");
964
+ expect(dialog.props["aria-describedby"]).toBeTruthy();
965
+ expect(textContent(description)).toContain("Includes balcony access.");
966
+ });
967
+ it("renders door-only sticky messaging as information, not a button", () => {
968
+ const item = widget();
969
+ item.stickyCheckout = true;
970
+ item.event = event({
971
+ admissions: [
972
+ admission({
973
+ id: "adm_door",
974
+ saleEndsAt: pastStart,
975
+ saleEndsBehavior: "DOOR",
976
+ }),
977
+ ],
978
+ });
979
+ const view = item.renderReadyState();
980
+ const sticky = rendered(view, (node) => hasClass(node, "sticky-checkout-bar"));
981
+ expect(sticky).toHaveLength(1);
982
+ expect(textContent(sticky[0]).toLowerCase()).toContain("at the door");
983
+ expect(rendered(sticky[0], (node) => node.tag === "button")).toHaveLength(0);
984
+ });
985
+ it("recreates analytics for a new client without replaying a refresh view", async () => {
986
+ const item = widget();
987
+ const firstAnalytics = { page: vi.fn(), track: vi.fn() };
988
+ const secondAnalytics = { page: vi.fn(), track: vi.fn() };
989
+ const firstClient = {
990
+ event: { get: vi.fn().mockResolvedValue({ event: event() }) },
991
+ order: {
992
+ create: vi.fn(),
993
+ quote: vi.fn().mockResolvedValue({ quote: quote() }),
994
+ },
995
+ };
996
+ const secondEvent = event({ id: "evt_2", slug: "event-2" });
997
+ const secondClient = {
998
+ event: { get: vi.fn().mockResolvedValue({ event: secondEvent }) },
999
+ order: {
1000
+ create: vi.fn(),
1001
+ quote: vi.fn().mockResolvedValue({ quote: quote() }),
1002
+ },
1003
+ };
1004
+ createClient
1005
+ .mockReturnValueOnce(firstClient)
1006
+ .mockReturnValueOnce(secondClient);
1007
+ createAnalytics
1008
+ .mockReturnValueOnce(firstAnalytics)
1009
+ .mockReturnValueOnce(secondAnalytics);
1010
+ item.event = null;
1011
+ item.initialLoading = true;
1012
+ item.loading = true;
1013
+ await item.initializeComponent();
1014
+ await item.loadEventData();
1015
+ expect(firstAnalytics.page).toHaveBeenCalledOnce();
1016
+ item.tenantId = "tnt_2";
1017
+ item.eventId = secondEvent.id;
1018
+ await item.initializeComponent();
1019
+ expect(createAnalytics).toHaveBeenCalledTimes(2);
1020
+ expect(createAnalytics).toHaveBeenNthCalledWith(1, expect.objectContaining({ client: firstClient }));
1021
+ expect(createAnalytics).toHaveBeenNthCalledWith(2, expect.objectContaining({ client: secondClient }));
1022
+ expect(secondAnalytics.page).toHaveBeenCalledOnce();
1023
+ });
503
1024
  });