@session-services/web-elements 0.1.28 → 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 (37) hide show
  1. package/README.md +5 -0
  2. package/dist/cjs/{index-BYBgrATL.js → index-B2ZmNnji.js} +292 -245
  3. package/dist/cjs/loader.cjs.js +2 -2
  4. package/dist/cjs/session-services-elements.cjs.js +3 -3
  5. package/dist/cjs/session-services-formatted-text.cjs.entry.js +8 -20
  6. package/dist/cjs/session-services-ticketing.cjs.entry.js +2188 -23012
  7. package/dist/collection/collection-manifest.json +4 -3
  8. package/dist/collection/components/session-services-ticketing/bot-detection.js +83 -0
  9. package/dist/collection/components/session-services-ticketing/bot-detection.test.js +74 -0
  10. package/dist/collection/components/session-services-ticketing/session-services-ticketing.css +164 -36
  11. package/dist/collection/components/session-services-ticketing/session-services-ticketing.js +811 -262
  12. package/dist/collection/components/session-services-ticketing/session-services-ticketing.logic.js +39 -8
  13. package/dist/collection/components/session-services-ticketing/session-services-ticketing.logic.test.js +47 -3
  14. package/dist/collection/components/session-services-ticketing/session-services-ticketing.test.js +611 -20
  15. package/dist/components/index.d.ts +2 -0
  16. package/dist/components/index.js +1 -1311
  17. package/dist/components/session-services-formatted-text.js +1 -1802
  18. package/dist/components/session-services-ticketing.js +1 -25072
  19. package/dist/esm/{index-CNOpF6tk.js → index-DhbVMqIT.js} +292 -245
  20. package/dist/esm/loader.js +3 -3
  21. package/dist/esm/session-services-elements.js +4 -4
  22. package/dist/esm/session-services-formatted-text.entry.js +8 -20
  23. package/dist/esm/session-services-ticketing.entry.js +2188 -23012
  24. package/dist/session-services-elements/{p-703444ed.entry.js → p-8483ca88.entry.js} +1 -1
  25. package/dist/session-services-elements/p-DhbVMqIT.js +2 -0
  26. package/dist/session-services-elements/p-d7264f6b.entry.js +1 -0
  27. package/dist/session-services-elements/session-services-elements.esm.js +1 -1
  28. package/dist/types/components/session-services-ticketing/bot-detection.d.ts +13 -0
  29. package/dist/types/components/session-services-ticketing/bot-detection.test.d.ts +1 -0
  30. package/dist/types/components/session-services-ticketing/session-services-ticketing.d.ts +67 -9
  31. package/dist/types/components/session-services-ticketing/session-services-ticketing.logic.d.ts +11 -2
  32. package/dist/types/components.d.ts +36 -8
  33. package/dist/types/index.d.ts +1 -1
  34. package/dist/types/stencil-public-runtime.d.ts +67 -5
  35. package/package.json +15 -11
  36. package/dist/session-services-elements/p-1acfcdfd.entry.js +0 -1
  37. package/dist/session-services-elements/p-CNOpF6tk.js +0 -2
@@ -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,
@@ -129,6 +141,15 @@ function rendered(value, predicate) {
129
141
  const matches = predicate(value) ? [value] : [];
130
142
  return matches.concat(value.children.flatMap((item) => rendered(item, predicate)));
131
143
  }
144
+ function textContent(value) {
145
+ if (typeof value === "string")
146
+ return value;
147
+ if (Array.isArray(value))
148
+ return value.map(textContent).join(" ");
149
+ if (!isRenderNode(value))
150
+ return "";
151
+ return textContent(value.children);
152
+ }
132
153
  function hasClass(node, name) {
133
154
  return String(node.props.class ?? "")
134
155
  .split(/\s+/)
@@ -136,6 +157,7 @@ function hasClass(node, name) {
136
157
  }
137
158
  function widget() {
138
159
  const item = new SessionsTicketing();
160
+ item.apiUrl = "https://api.session.services";
139
161
  item.eventId = "evt_1";
140
162
  item.event = event();
141
163
  item.enableAnalytics = true;
@@ -146,20 +168,30 @@ function widget() {
146
168
  item.quote = null;
147
169
  item.previewQuote = null;
148
170
  item.quoteError = null;
171
+ item.checkoutError = null;
149
172
  item.couponError = null;
150
173
  item.appliedCouponCode = "";
151
174
  item.couponInput = "";
152
175
  item.couponCode = undefined;
153
176
  item.initialPreviewQuote = undefined;
154
177
  item.quoteLoading = false;
178
+ item.checkoutLoading = false;
155
179
  item.previewLoading = false;
156
180
  item.passcodeDenied = false;
157
181
  item.stickyCheckout = false;
182
+ item.tenantId = "tnt_1";
183
+ item.theme = "light";
158
184
  item.error = null;
185
+ item.errorKind = null;
186
+ item.initialLoading = false;
187
+ item.eventLoading = false;
159
188
  item.activePasscode = "";
189
+ item.passcodeInput = "";
190
+ item.passcodeLoading = false;
160
191
  item.cartUpdate = { emit: vi.fn() };
161
192
  item.checkoutInit = { emit: vi.fn() };
162
193
  item.errorOccurred = { emit: vi.fn() };
194
+ item.analyticsEventSkipped = { emit: vi.fn() };
163
195
  item.client = {
164
196
  event: {
165
197
  get: vi.fn(),
@@ -172,6 +204,91 @@ function widget() {
172
204
  return item;
173
205
  }
174
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
+ });
175
292
  it("clears stale cart quotes when requoting fails", async () => {
176
293
  const item = widget();
177
294
  item.quote = quote();
@@ -182,6 +299,24 @@ describe("session-services-ticketing component", () => {
182
299
  expect(item.quoteError).toBe("Failed to update cart. Please try again.");
183
300
  expect(item.isCheckoutDisabled()).toBe(true);
184
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
+ });
185
320
  it("does not commit failed cart coupon applications", async () => {
186
321
  const item = widget();
187
322
  const current = quote();
@@ -189,15 +324,18 @@ describe("session-services-ticketing component", () => {
189
324
  item.appliedCouponCode = "SAVE10";
190
325
  item.couponInput = "bad";
191
326
  item.selectedItems = new Map([[selectionKey("adm_general", null), 1]]);
327
+ item.client.event.get = vi.fn().mockResolvedValue({
328
+ event: item.event,
329
+ });
192
330
  item.client.order.quote = vi
193
331
  .fn()
194
332
  .mockRejectedValue(new Error("Invalid coupon"));
195
333
  await item.applyCoupon();
196
334
  expect(item.appliedCouponCode).toBe("SAVE10");
197
- expect(item.couponInput).toBe("BAD");
335
+ expect(item.couponInput).toBe("SAVE10");
198
336
  expect(item.quote).toBe(current);
199
337
  expect(item.couponError).toBe("Failed to apply coupon. Please check the code.");
200
- 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" }));
201
339
  });
202
340
  it("does not commit preview coupons when event reload fails", async () => {
203
341
  const item = widget();
@@ -206,7 +344,7 @@ describe("session-services-ticketing component", () => {
206
344
  item.loadEventData = vi.fn().mockRejectedValue(new Error("Down"));
207
345
  await item.applyCoupon();
208
346
  expect(item.appliedCouponCode).toBe("SAVE10");
209
- expect(item.couponInput).toBe("BAD");
347
+ expect(item.couponInput).toBe("SAVE10");
210
348
  expect(item.couponError).toBe("Failed to apply coupon. Please check the code.");
211
349
  });
212
350
  it("does not apply stale cart coupons as preview coupons", async () => {
@@ -219,24 +357,61 @@ describe("session-services-ticketing component", () => {
219
357
  .fn()
220
358
  .mockReturnValueOnce(firstLoad.promise)
221
359
  .mockReturnValueOnce(secondLoad.promise)
222
- .mockResolvedValue(undefined);
360
+ .mockResolvedValue({
361
+ status: "success",
362
+ event: item.event,
363
+ });
223
364
  item.client.order.quote = vi.fn().mockResolvedValue({ quote: quote() });
224
365
  const stale = item.applyCoupon();
225
366
  await Promise.resolve();
226
- expect(item.appliedCouponCode).toBe("OLD");
367
+ expect(item.appliedCouponCode).toBe("");
227
368
  item.couponInput = "new";
228
369
  const latest = item.applyCoupon();
229
370
  await Promise.resolve();
230
- expect(item.appliedCouponCode).toBe("NEW");
231
- item.selectedItems = new Map();
232
- firstLoad.resolve();
371
+ expect(item.appliedCouponCode).toBe("");
372
+ firstLoad.resolve({
373
+ status: "success",
374
+ event: item.event,
375
+ });
233
376
  await stale;
234
- expect(item.appliedCouponCode).toBe("NEW");
235
- expect(item.client.order.quote).not.toHaveBeenCalledWith(expect.objectContaining({ couponCode: "OLD" }));
236
- 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
+ });
237
385
  await latest;
238
386
  expect(item.appliedCouponCode).toBe("NEW");
239
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
+ });
240
415
  it("keeps preview quote loading separate from cart quote loading", async () => {
241
416
  const item = widget();
242
417
  const next = deferred();
@@ -321,6 +496,12 @@ describe("session-services-ticketing component", () => {
321
496
  expect(increments).toHaveLength(1);
322
497
  expect(checkout).toHaveLength(1);
323
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
+ });
324
505
  it("renders external ticketing in the sticky checkout bar", () => {
325
506
  const item = widget();
326
507
  item.event = event({ url: "https://tickets.example" });
@@ -357,10 +538,16 @@ describe("session-services-ticketing component", () => {
357
538
  const links = rendered(item.renderReadyState(), (node) => node.tag === "a");
358
539
  const inline = links.find((link) => hasClass(link, "inline-external-ticket-button"));
359
540
  const sticky = links.find((link) => hasClass(link, "sticky-checkout-btn"));
360
- expect(typeof inline?.props.onClick).toBe("function");
361
- expect(typeof sticky?.props.onClick).toBe("function");
362
- (inline?.props.onClick)();
363
- (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();
364
551
  expect(track).toHaveBeenCalledWith("Checkout Started", expect.objectContaining({
365
552
  event_id: "evt_1",
366
553
  team_id: "team_1",
@@ -377,22 +564,116 @@ describe("session-services-ticketing component", () => {
377
564
  cta_surface: "sticky",
378
565
  }));
379
566
  });
567
+ it("tracks page, product, and product list views after event load", () => {
568
+ const item = widget();
569
+ const page = vi.fn();
570
+ const track = vi.fn();
571
+ item.analytics = { page, track };
572
+ item.trackLoadedEvent(item.event);
573
+ expect(page).toHaveBeenCalledWith("Ticketing Widget", expect.objectContaining({
574
+ event_id: "evt_1",
575
+ team_id: "team_1",
576
+ promoter_id: "pro_1",
577
+ event_name: "Test Event",
578
+ event_slug: "event",
579
+ }));
580
+ expect(track).toHaveBeenCalledWith("Product Viewed", expect.objectContaining({
581
+ event_id: "evt_1",
582
+ product_id: "evt_1",
583
+ value: 22,
584
+ price: 22,
585
+ currency: "AUD",
586
+ }));
587
+ expect(track).toHaveBeenCalledWith("Product List Viewed", expect.objectContaining({
588
+ list_id: "evt_1",
589
+ products: [
590
+ expect.objectContaining({
591
+ admission_id: "adm_general",
592
+ price: 22,
593
+ price_base: 2000,
594
+ price_booking_fee: 200,
595
+ }),
596
+ ],
597
+ }));
598
+ expect(item.analyticsEventSkipped.emit).not.toHaveBeenCalled();
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
+ });
632
+ it("emits an analytics diagnostic when event identity is incomplete", () => {
633
+ const item = widget();
634
+ const page = vi.fn();
635
+ const track = vi.fn();
636
+ item.analytics = { page, track };
637
+ const missingTeam = event({ teamId: "" });
638
+ item.event = missingTeam;
639
+ item.trackLoadedEvent(missingTeam);
640
+ expect(page).not.toHaveBeenCalled();
641
+ expect(track).not.toHaveBeenCalled();
642
+ expect(item.analyticsEventSkipped.emit).toHaveBeenCalledWith({
643
+ eventType: "Page Viewed",
644
+ eventId: "evt_1",
645
+ reason: "missing_team_id",
646
+ promoterId: "pro_1",
647
+ teamId: undefined,
648
+ });
649
+ });
380
650
  it("tracks hosted checkout starts with the created order id", async () => {
381
651
  const item = widget();
382
652
  const track = vi.fn();
383
653
  item.analytics = { track };
384
- item.quote = quote();
385
654
  item.selectedItems = new Map([[selectionKey("adm_general", null), 1]]);
655
+ item.client.order.quote = vi.fn().mockResolvedValue({ quote: quote() });
386
656
  item.client.order.create = vi.fn().mockResolvedValue({
387
657
  order: {
388
658
  checkoutUrl: "https://checkout.example/ord_1",
389
659
  id: "ord_1",
390
660
  },
391
661
  });
662
+ await item.updateQuote();
392
663
  await item.initiateCheckout();
393
664
  expect(track).toHaveBeenCalledWith("Checkout Started", expect.objectContaining({
394
665
  order_id: "ord_1",
395
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
+ ],
396
677
  }));
397
678
  });
398
679
  it("scrolls to the ticket selector with a 100px top offset", () => {
@@ -407,13 +688,29 @@ describe("session-services-ticketing component", () => {
407
688
  },
408
689
  getBoundingClientRect: () => ({ top: 900 }),
409
690
  };
410
- vi.stubGlobal("window", { scrollY: 250, scrollTo });
411
- item.handleStickyCheckoutClick(getTicketingState(item.event));
691
+ vi.stubGlobal("window", {
692
+ matchMedia: vi.fn().mockReturnValue({ matches: false }),
693
+ scrollY: 250,
694
+ scrollTo,
695
+ });
696
+ item.handleStickyCheckoutClick(getTicketingState(event()));
412
697
  expect(scrollTo).toHaveBeenCalledWith({
413
698
  behavior: "smooth",
414
699
  top: 570,
415
700
  });
416
- vi.unstubAllGlobals();
701
+ });
702
+ it("shows only the placeholder name for secret event locations", () => {
703
+ const item = widget();
704
+ const node = item.renderEventLocation(event({
705
+ locationRevealAt: futureStart,
706
+ location: {
707
+ ...event().location,
708
+ name: "Secret location",
709
+ },
710
+ }), "admission-venue");
711
+ expect(node.children).toHaveLength(2);
712
+ expect(node.children).toContain("Secret location");
713
+ expect(textContent(node)).not.toContain("Address revealed");
417
714
  });
418
715
  it("classifies only passcode access failures as passcode errors", async () => {
419
716
  const item = widget();
@@ -430,4 +727,298 @@ describe("session-services-ticketing component", () => {
430
727
  await item.loadEventData();
431
728
  expect(item.passcodeDenied).toBe(true);
432
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
+ });
433
1024
  });