@tribe-nest/forge 3.54.0 → 3.58.0

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 (102) hide show
  1. package/package.json +4 -2
  2. package/src/_tests/workspaceAliases.ts +1 -0
  3. package/src/contexts/CartContext.tsx +88 -5
  4. package/src/data/queries/useCallBreakouts.ts +112 -0
  5. package/src/data/queries/useCheckouts.ts +15 -2
  6. package/src/data/queries/useCoachingProducts.ts +15 -1
  7. package/src/data/queries/useCourses.ts +15 -1
  8. package/src/data/queries/useEvents.ts +80 -1
  9. package/src/data/queries/useMembership.ts +176 -4
  10. package/src/data/queries/useMyBookings.ts +16 -0
  11. package/src/data/queries/useNotifications.ts +1 -1
  12. package/src/data/queries/useProducts.ts +15 -1
  13. package/src/data/queries/useSubscriptions.ts +21 -1
  14. package/src/i18n/de.json +153 -0
  15. package/src/i18n/en.json +153 -0
  16. package/src/index.ts +1 -0
  17. package/src/server/_tests/buildPwaHead.spec.ts +1 -1
  18. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  19. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
  20. package/src/server/index.ts +75 -1
  21. package/src/server/platformEvents.generated.ts +60 -0
  22. package/src/server/pwa.ts +1 -1
  23. package/src/types/models.ts +137 -0
  24. package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
  25. package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
  26. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
  27. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
  28. package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
  29. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
  30. package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
  31. package/src/ui/headless/checkout/seatConflict.ts +117 -0
  32. package/src/ui/headless/checkout/useCheckout.ts +178 -55
  33. package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
  34. package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
  35. package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
  36. package/src/ui/headless/event/useEventCheckout.ts +362 -22
  37. package/src/ui/headless/event/useEventSeating.ts +233 -0
  38. package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
  39. package/src/ui/headless/forms/useEmailListForm.ts +37 -6
  40. package/src/ui/headless/forms/usePhoneInput.ts +294 -0
  41. package/src/ui/headless/forms/useSectionedForm.ts +2 -2
  42. package/src/ui/headless/index.ts +48 -1
  43. package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
  44. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
  45. package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
  46. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
  47. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
  48. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
  49. package/src/ui/headless/membership/membershipApplication.ts +109 -0
  50. package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
  51. package/src/ui/headless/membership/membershipTrial.ts +53 -0
  52. package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
  53. package/src/ui/index.ts +16 -0
  54. package/src/ui/media/CallStage.tsx +107 -10
  55. package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
  56. package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
  57. package/src/ui/media/_tests/callState.spec.ts +103 -1
  58. package/src/ui/media/callBreakouts.ts +57 -0
  59. package/src/ui/media/callState.ts +83 -23
  60. package/src/ui/media/index.ts +11 -0
  61. package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
  62. package/src/ui/media/panels/CallSidePanel.tsx +37 -4
  63. package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
  64. package/src/ui/media/stage/CallControlsBar.tsx +46 -0
  65. package/src/ui/media/stage/CallTopBar.tsx +1 -1
  66. package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
  67. package/src/ui/payment/ForgeStripePayment.tsx +78 -7
  68. package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
  69. package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
  70. package/src/ui/payment/stripeIntentKind.ts +32 -0
  71. package/src/ui/shell/TribeNestApp.tsx +1 -1
  72. package/src/ui/styled/AccountDashboard.tsx +165 -2
  73. package/src/ui/styled/BundleConfirmation.tsx +47 -2
  74. package/src/ui/styled/Cart.tsx +211 -135
  75. package/src/ui/styled/Checkout.tsx +163 -32
  76. package/src/ui/styled/EmailListForm.tsx +7 -9
  77. package/src/ui/styled/EventTickets.tsx +143 -4
  78. package/src/ui/styled/LoginForm.tsx +5 -3
  79. package/src/ui/styled/MembershipCheckout.tsx +596 -256
  80. package/src/ui/styled/MembershipTierCallout.tsx +17 -3
  81. package/src/ui/styled/MembershipTiers.tsx +148 -6
  82. package/src/ui/styled/PhoneInput.tsx +127 -0
  83. package/src/ui/styled/ProductGrid.tsx +14 -2
  84. package/src/ui/styled/SignupForm.tsx +4 -2
  85. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
  86. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
  87. package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
  88. package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
  89. package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
  90. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
  91. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
  92. package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
  93. package/src/ui/styled/forge-utilities.css +67 -0
  94. package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
  95. package/src/ui/styled/seating/SeatLegend.tsx +73 -0
  96. package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
  97. package/src/ui/styled/seating/paint.ts +79 -0
  98. package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
  99. package/src/ui/styled/seating/viewport.ts +86 -0
  100. package/src/utils/_tests/engagementTracker.spec.ts +111 -0
  101. package/src/utils/engagementTracker.ts +73 -0
  102. package/src/utils/landing.ts +1 -1
@@ -307,6 +307,116 @@ export type MembershipTier = {
307
307
  welcomeMessageContent?: string | null;
308
308
  cancellationMessageSubject?: string | null;
309
309
  cancellationMessageContent?: string | null;
310
+ /**
311
+ * How a fan gets onto this tier (curated access). `open` is the ordinary
312
+ * checkout; `invite` needs a live invite for the signed-in email (and the
313
+ * tier is only listed when the request carried that invite's token);
314
+ * `application` means the fan applies and the business approves. Optional,
315
+ * because an older API build sends nothing, which reads as `open`.
316
+ */
317
+ joinMode?: MembershipJoinMode;
318
+ /**
319
+ * The signed-in fan's most recent open application on this tier (or their
320
+ * latest declined one), so the tier card can show where it stands instead of
321
+ * a join button. Absent when signed out or on an older API build.
322
+ */
323
+ myApplication?: MembershipTierMyApplication | null;
324
+ /**
325
+ * Free trial length in days on a paid tier (1 to 90), or null for no trial.
326
+ * A card is still collected; the first charge is on today + trialDays.
327
+ */
328
+ trialDays?: number | null;
329
+ /**
330
+ * False once the signed-in fan has had a trial with this business (one per
331
+ * fan per business, any tier). Absent when signed out: treat as eligible.
332
+ */
333
+ trialEligible?: boolean;
334
+ /**
335
+ * Questions the fan answers before paying or joining, on every join mode.
336
+ * Null or absent when the tier asks nothing.
337
+ */
338
+ questionnaire?: QuestionnaireQuestion[] | null;
339
+ };
340
+
341
+ /** How a fan gets onto a membership tier. */
342
+ export type MembershipJoinMode = "open" | "invite" | "application";
343
+
344
+ /**
345
+ * Where an application to a membership tier stands.
346
+ *
347
+ * card_pending paid tier: card step started, not confirmed. Not yet visible to the business.
348
+ * pending lodged and waiting for a decision; holds a seat
349
+ * approved approved; the subscription is being created
350
+ * payment_failed approved, but the saved card was declined; the fan can finish at checkout
351
+ * joined the membership exists
352
+ * declined not approved
353
+ * withdrawn withdrawn by the fan, or closed because the tier filled (`closedReason: "tier_full"`)
354
+ * expired the window to finish a failed payment lapsed
355
+ */
356
+ export type MembershipApplicationStatus =
357
+ | "card_pending"
358
+ | "pending"
359
+ | "approved"
360
+ | "payment_failed"
361
+ | "joined"
362
+ | "declined"
363
+ | "withdrawn"
364
+ | "expired";
365
+
366
+ /** The slice of an application the public tier list carries per tier. */
367
+ export type MembershipTierMyApplication = {
368
+ id: string;
369
+ status: MembershipApplicationStatus;
370
+ paymentWindowExpiresAt: string | null;
371
+ };
372
+
373
+ export type MembershipApplication = {
374
+ id: string;
375
+ profileId: string;
376
+ membershipTierId: string;
377
+ /** Present on list payloads. */
378
+ membershipTierName?: string;
379
+ accountId: string;
380
+ status: MembershipApplicationStatus;
381
+ /** Null on a free tier. */
382
+ billingCycle: "month" | "year" | null;
383
+ /** What will be charged per cycle, in major units. Null on a free tier. */
384
+ amount: number | null;
385
+ currency: string | null;
386
+ /** A note from an older checkout. Current checkouts ask the tier's questions instead. */
387
+ message: string | null;
388
+ /** The fan's answers to the tier's questions, as given when applying. */
389
+ questionnaire?: QuestionnaireAnswer[] | null;
390
+ /** e.g. "Visa •••• 4242", set once the card is confirmed. */
391
+ instrumentLabel: string | null;
392
+ decidedAt: string | null;
393
+ decisionNote: string | null;
394
+ /** `"tier_full"`, `"expired"`, or the provider's decline reason on `payment_failed`. */
395
+ closedReason: string | null;
396
+ /** The deadline to finish a `payment_failed` application. */
397
+ paymentWindowExpiresAt: string | null;
398
+ membershipId: string | null;
399
+ createdAt: string;
400
+ updatedAt: string;
401
+ };
402
+
403
+ /** What `POST /public/membership-applications` answers. */
404
+ export type ApplyForMembershipResult = {
405
+ application: MembershipApplication;
406
+ /** True on a paid tier until the card is confirmed. */
407
+ requiresCard: boolean;
408
+ /** A Stripe SetupIntent secret (`seti_..._secret_...`) when `requiresCard`. */
409
+ clientSecret: string | null;
410
+ };
411
+
412
+ /** What `GET /public/membership-invites/info` answers for a live invite. */
413
+ export type MembershipInviteInfo = {
414
+ membershipTierId: string;
415
+ membershipTierName: string;
416
+ profileId: string;
417
+ /** The address the invite was sent to; the fan must be signed in with it. */
418
+ email: string;
419
+ expiresAt: string;
310
420
  };
311
421
 
312
422
  /**
@@ -346,6 +456,10 @@ export interface Membership {
346
456
  subscriptionAmount: number;
347
457
  subscriptionCurrency: string;
348
458
  billingCycle: string;
459
+ /** Set when the membership started with a free trial: when the first charge is due. */
460
+ trialEndsAt?: string | null;
461
+ /** The fan's answers to the tier's questions, as given when joining. */
462
+ questionnaire?: QuestionnaireAnswer[] | null;
349
463
  }
350
464
 
351
465
  // ---- Public auth -------------------------------------------------------------
@@ -911,6 +1025,19 @@ export type QuestionnaireQuestion = {
911
1025
  optional?: boolean;
912
1026
  };
913
1027
 
1028
+ /**
1029
+ * A fan's answer to one {@link QuestionnaireQuestion}, as sent at checkout and
1030
+ * as stored on what the checkout created. `answer` is `""` for an optional
1031
+ * question left blank.
1032
+ */
1033
+ export type QuestionnaireAnswer = {
1034
+ id: string;
1035
+ question: string;
1036
+ type: string;
1037
+ options?: string[];
1038
+ answer: string;
1039
+ };
1040
+
914
1041
  /**
915
1042
  * S.6 — the cancellation terms a buyer is entitled to read BEFORE they pay.
916
1043
  *
@@ -1170,6 +1297,16 @@ export interface IEvent {
1170
1297
  * than trusting its absence.
1171
1298
  */
1172
1299
  hasApprovalTiers?: boolean;
1300
+ /**
1301
+ * This event sells RESERVED seats from a bound seat map (seating wave 2).
1302
+ *
1303
+ * When true, `GET /public/events/:id/seating` returns the frozen layout and
1304
+ * the per-seat availability, and a tier that has seats on that map requires
1305
+ * `seats[ticketId]` on the order. Optional on the wire: an API build that
1306
+ * predates seat maps sends nothing, and a renderer must treat absent as
1307
+ * "general admission only", which is exactly what every event was before.
1308
+ */
1309
+ hasSeatMap?: boolean;
1173
1310
  media: IMedia[];
1174
1311
  slug: string;
1175
1312
  /**
@@ -4,6 +4,7 @@ import { useSiteConfig } from "../../data/queries/useWebsite";
4
4
  import { initMetaPixelScoped, fireMetaEventSingle } from "../../utils/metaPixel";
5
5
  import { setCurrentPageEventId, isProfilePixelSuppressed } from "../../utils/analyticsBus";
6
6
  import { useCookieConsent } from "../headless/consent/useCookieConsent";
7
+ import { createEngagementTracker, scrollDepthOf, type EngagementTracker } from "../../utils/engagementTracker";
7
8
 
8
9
  export interface ForgeAnalyticsProps {
9
10
  /**
@@ -58,6 +59,11 @@ function currentPage(): string {
58
59
  return window.location.pathname + window.location.search;
59
60
  }
60
61
 
62
+ /** Engaged means the visitor can see the page and it has focus. */
63
+ function isEngaged(): boolean {
64
+ return typeof document !== "undefined" && document.visibilityState === "visible" && document.hasFocus();
65
+ }
66
+
61
67
  /**
62
68
  * Drop-in website analytics. Render once, high in the tree (inside
63
69
  * `ForgeProvider`), and it wires the site into the platform's analytics:
@@ -65,6 +71,8 @@ function currentPage(): string {
65
71
  * - Auto page views on every navigation → `POST /public/websites/track-event`,
66
72
  * surfaced in the admin analytics dashboard (visits, pages, geo).
67
73
  * - Auto click tracking on links/buttons (opt out with `trackClicks={false}`).
74
+ * - Time on page and scroll depth, as `engagement` events (engaged time only:
75
+ * visible and focused).
68
76
  * - Optional Meta Pixel: pass `metaPixelId` to init the pixel and mirror each
69
77
  * page view as a Meta `PageView` (event-id matched for CAPI de-dup).
70
78
  *
@@ -95,8 +103,19 @@ export function ForgeAnalytics({
95
103
  // runs regardless but only stores a device session once the visitor turns
96
104
  // Performance on — otherwise it stays cookieless (backend derives the id).
97
105
  const storeSession = cookieless ? perfConsent : true;
98
- const { track } = useTrackEvent({ storeSession });
106
+ const { track, trackBeacon } = useTrackEvent({ storeSession });
99
107
  const lastPage = useRef<string | null>(null);
108
+ // Time on page. Pings go by beacon: most are sent as the page is hidden or
109
+ // unloaded, which cancels an ordinary request.
110
+ const beaconRef = useRef(trackBeacon);
111
+ beaconRef.current = trackBeacon;
112
+ const engagement = useRef<EngagementTracker | null>(null);
113
+ if (engagement.current === null) {
114
+ engagement.current = createEngagementTracker({
115
+ now: () => Date.now(),
116
+ send: (ping) => beaconRef.current("engagement", ping),
117
+ });
118
+ }
100
119
 
101
120
  // Consent gates. The Meta Pixel always needs `marketing`. First-party analytics
102
121
  // needs `performance` — UNLESS it's cookieless (stores nothing without consent),
@@ -129,6 +148,10 @@ export function ForgeAnalytics({
129
148
  // can dedupe against the same page view.
130
149
  setCurrentPageEventId(eventId);
131
150
  if (firstPartyOn) {
151
+ // Close the previous page's time before this page view is recorded, so
152
+ // the ping lands on the page it measured.
153
+ engagement.current?.flush();
154
+ engagement.current?.start(currentPathname(), isEngaged());
132
155
  track("page_view", {
133
156
  // `pathname` (full resolved path) is what the admin "top pages" report
134
157
  // groups on; `page` keeps the query string for detail.
@@ -178,6 +201,37 @@ export function ForgeAnalytics({
178
201
  };
179
202
  }, [firstPartyOn, metaOn, pixelId, track]);
180
203
 
204
+ // Engagement: first-party only, like the page views it belongs to.
205
+ useEffect(() => {
206
+ const tracker = engagement.current;
207
+ if (!firstPartyOn || !tracker || typeof window === "undefined") return;
208
+
209
+ const pause = () => {
210
+ tracker.deactivate();
211
+ tracker.flush();
212
+ };
213
+ const resume = () => {
214
+ if (isEngaged()) tracker.activate();
215
+ };
216
+ const onVisibility = () => (document.visibilityState === "visible" ? resume() : pause());
217
+ const onScroll = () => tracker.scrolled(scrollDepthOf(document, window));
218
+
219
+ onScroll();
220
+ document.addEventListener("visibilitychange", onVisibility);
221
+ window.addEventListener("blur", pause);
222
+ window.addEventListener("focus", resume);
223
+ window.addEventListener("pagehide", pause);
224
+ window.addEventListener("scroll", onScroll, { passive: true });
225
+ return () => {
226
+ pause();
227
+ document.removeEventListener("visibilitychange", onVisibility);
228
+ window.removeEventListener("blur", pause);
229
+ window.removeEventListener("focus", resume);
230
+ window.removeEventListener("pagehide", pause);
231
+ window.removeEventListener("scroll", onScroll);
232
+ };
233
+ }, [firstPartyOn]);
234
+
181
235
  // Click tracking — first-party only, one delegated listener for the document.
182
236
  useEffect(() => {
183
237
  if (!firstPartyOn || !trackClicks || typeof document === "undefined") return;
@@ -0,0 +1,78 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ import { render, act, cleanup } from "@testing-library/react";
4
+
5
+ /**
6
+ * The wiring around the engagement tracker: which page a ping is attributed
7
+ * to, and which transport carries it. The ordering on an SPA navigation is the
8
+ * part that breaks quietly: send the ping after the new page view and every
9
+ * page's time lands on the page the visitor went to next.
10
+ */
11
+
12
+ const calls: { via: "track" | "beacon"; eventType: string; data: Record<string, unknown> }[] = [];
13
+
14
+ vi.mock("../../../data/queries/useAnalytics", () => ({
15
+ newEventId: () => "evt",
16
+ useTrackEvent: () => ({
17
+ track: (eventType: string, data: Record<string, unknown>) => calls.push({ via: "track", eventType, data }),
18
+ trackBeacon: (eventType: string, data: Record<string, unknown>) => calls.push({ via: "beacon", eventType, data }),
19
+ }),
20
+ }));
21
+ vi.mock("../../../data/queries/useWebsite", () => ({ useSiteConfig: () => ({ data: undefined }) }));
22
+ vi.mock("../../headless/consent/useCookieConsent", () => ({
23
+ useCookieConsent: () => ({ performance: true, marketing: false }),
24
+ }));
25
+
26
+ import { ForgeAnalytics } from "../ForgeAnalytics";
27
+
28
+ describe("<ForgeAnalytics /> engagement", () => {
29
+ beforeEach(() => {
30
+ calls.length = 0;
31
+ vi.useFakeTimers({ toFake: ["Date"] });
32
+ vi.setSystemTime(0);
33
+ vi.spyOn(document, "hasFocus").mockReturnValue(true);
34
+ window.history.replaceState({}, "", "/first");
35
+ });
36
+
37
+ afterEach(() => {
38
+ cleanup();
39
+ vi.useRealTimers();
40
+ vi.restoreAllMocks();
41
+ });
42
+
43
+ const events = () => calls.map((c) => [c.via, c.eventType, c.data.pathname, c.data.engagedMs]);
44
+
45
+ it("flushes the old page's time by beacon BEFORE the next page view", () => {
46
+ render(<ForgeAnalytics trackClicks={false} />);
47
+ vi.setSystemTime(7000);
48
+ act(() => window.history.pushState({}, "", "/second"));
49
+
50
+ expect(events()).toEqual([
51
+ ["track", "page_view", "/first", undefined],
52
+ ["beacon", "engagement", "/first", 7000],
53
+ ["track", "page_view", "/second", undefined],
54
+ ]);
55
+ });
56
+
57
+ it("sends a ping when the window loses focus, and stops the clock until it returns", () => {
58
+ render(<ForgeAnalytics trackClicks={false} />);
59
+ vi.setSystemTime(4000);
60
+ act(() => void window.dispatchEvent(new Event("blur")));
61
+ vi.setSystemTime(100_000);
62
+ act(() => void window.dispatchEvent(new Event("focus")));
63
+ vi.setSystemTime(103_000);
64
+ act(() => void window.dispatchEvent(new Event("pagehide")));
65
+
66
+ expect(events().filter((e) => e[1] === "engagement")).toEqual([
67
+ ["beacon", "engagement", "/first", 4000],
68
+ ["beacon", "engagement", "/first", 3000],
69
+ ]);
70
+ });
71
+
72
+ it("records nothing, pings included, when disabled", () => {
73
+ render(<ForgeAnalytics enabled={false} />);
74
+ vi.setSystemTime(10_000);
75
+ act(() => void window.dispatchEvent(new Event("pagehide")));
76
+ expect(calls).toEqual([]);
77
+ });
78
+ });
@@ -69,7 +69,9 @@ describe("bundleCouponSummary — applying", () => {
69
69
  it("claims no payment when the bundle had no intent to replace", () => {
70
70
  // A bundle that has not reached start-payment yet: the client's own
71
71
  // start-payment will pick the new figure up, so nothing may supersede it.
72
- const next = bundleCouponSummary(answer({ paymentSecret: "", paymentId: "", chargedAmount: 0, chargedCurrency: "" }));
72
+ const next = bundleCouponSummary(
73
+ answer({ paymentSecret: "", paymentId: "", chargedAmount: 0, chargedCurrency: "" }),
74
+ );
73
75
 
74
76
  expect(next.paymentUpdate).toBeNull();
75
77
  expect(next.chargedTotal).toBeNull();
@@ -103,16 +103,12 @@ describe("resumeLineToCartItem", () => {
103
103
  it("should keep an add-on bound to the event it hangs off", () => {
104
104
  // Without this the rebuilt bundle has an add-on with no base and the server
105
105
  // refuses the whole checkout, with nothing on screen explaining why.
106
- const item = resumeLineToCartItem(
107
- productLine({ attachedTo: { type: "event", entityId: "event-1" } }),
108
- );
106
+ const item = resumeLineToCartItem(productLine({ attachedTo: { type: "event", entityId: "event-1" } }));
109
107
  expect(item?.attachedTo).toEqual({ type: "event", entityId: "event-1" });
110
108
  });
111
109
 
112
110
  it("should carry the chosen variant's options so the cart can name it", () => {
113
- expect(resumeLineToCartItem(productLine())?.options).toEqual([
114
- { axis: "Size", value: "L", swatchHex: null },
115
- ]);
111
+ expect(resumeLineToCartItem(productLine())?.options).toEqual([{ axis: "Size", value: "L", swatchHex: null }]);
116
112
  });
117
113
 
118
114
  it("should refuse a line with no variant rather than invent one", () => {
@@ -192,10 +188,7 @@ describe("partitionResumeLines", () => {
192
188
  title: "Tour poster",
193
189
  attachedTo: { type: "event", entityId: "event-1" },
194
190
  });
195
- const parts = partitionResumeLines([
196
- ticketLine({ available: false, unavailableReason: "sold_out" }),
197
- poster,
198
- ]);
191
+ const parts = partitionResumeLines([ticketLine({ available: false, unavailableReason: "sold_out" }), poster]);
199
192
 
200
193
  expect(parts.restoredLines).toEqual([]);
201
194
  expect(parts.droppedLines).toEqual([poster]);
@@ -0,0 +1,187 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { pruneSeatsFromTicketItem, type TicketCartItem } from "../../../../contexts/CartContext";
3
+ import { cartToCheckoutLines } from "../../../../data/queries/useCheckouts";
4
+ import {
5
+ cartHasSeatedLines,
6
+ findSeatConflict,
7
+ seatedLinesOf,
8
+ seatsTakenConflict,
9
+ seatsTakenIdsOf,
10
+ } from "../seatConflict";
11
+ import { buildLayout, buildSeating, seatAt, STALLS_TIER, GA_TIER } from "../../event/_tests/seatingFixture";
12
+
13
+ /**
14
+ * Reserved seats riding in the bundle cart (seating wave 2, docs §12): what
15
+ * the cart line carries, what the create body sends, how a conflict is found
16
+ * and how the line is pruned. Pure, so no provider tree.
17
+ */
18
+
19
+ const seating = buildSeating(buildLayout());
20
+ const a1 = seatAt(seating, "Stalls", "A", "1");
21
+ const a2 = seatAt(seating, "Stalls", "A", "2");
22
+
23
+ const line = (overrides: Partial<TicketCartItem> = {}): TicketCartItem => ({
24
+ eventId: "ev-seated",
25
+ eventSlug: "seated-show",
26
+ eventTitle: "Seated show",
27
+ tickets: { [STALLS_TIER]: 2, [GA_TIER]: 1 },
28
+ ticketMeta: {
29
+ [STALLS_TIER]: {
30
+ title: "Stalls",
31
+ price: 40,
32
+ seatIds: [a1, a2],
33
+ seats: [
34
+ { id: a1, label: "Stalls · Row A · Seat 1" },
35
+ { id: a2, label: "Stalls · Row A · Seat 2" },
36
+ ],
37
+ },
38
+ [GA_TIER]: { title: "Standing", price: 20 },
39
+ },
40
+ bookingFee: null,
41
+ ...overrides,
42
+ });
43
+
44
+ describe("cartToCheckoutLines with seats", () => {
45
+ it("puts the seat ids on the seated tier's line and nothing on the GA line", () => {
46
+ const lines = cartToCheckoutLines([], [line()]);
47
+ expect(lines).toEqual([
48
+ {
49
+ type: "event_ticket",
50
+ eventId: "ev-seated",
51
+ ticketId: STALLS_TIER,
52
+ quantity: 2,
53
+ price: 40,
54
+ seatIds: [a1, a2],
55
+ title: "Stalls",
56
+ coverImage: undefined,
57
+ },
58
+ {
59
+ type: "event_ticket",
60
+ eventId: "ev-seated",
61
+ ticketId: GA_TIER,
62
+ quantity: 1,
63
+ price: 20,
64
+ title: "Standing",
65
+ coverImage: undefined,
66
+ },
67
+ ]);
68
+ expect(lines[1]).not.toHaveProperty("seatIds");
69
+ });
70
+ });
71
+
72
+ describe("seatedLinesOf / cartHasSeatedLines", () => {
73
+ it("names only the tiers that carry seats and are still in the selection", () => {
74
+ expect(seatedLinesOf(line())).toEqual([{ ticketId: STALLS_TIER, seatIds: [a1, a2] }]);
75
+ expect(cartHasSeatedLines([line()])).toBe(true);
76
+ expect(cartHasSeatedLines([line({ tickets: { [GA_TIER]: 1 } })])).toBe(false);
77
+ const plain = line({ tickets: { [GA_TIER]: 1 }, ticketMeta: { [GA_TIER]: { title: "Standing", price: 20 } } });
78
+ expect(cartHasSeatedLines([plain])).toBe(false);
79
+ });
80
+ });
81
+
82
+ describe("findSeatConflict", () => {
83
+ it("is null while every seat still reads available", () => {
84
+ expect(findSeatConflict(line(), seating)).toBeNull();
85
+ });
86
+
87
+ it("names a seat that no longer reads available, with the cart's label", () => {
88
+ const taken = buildSeating(buildLayout(), ["Stalls/A/2"]);
89
+ expect(findSeatConflict(line(), taken)).toEqual({
90
+ eventId: "ev-seated",
91
+ eventSlug: "seated-show",
92
+ eventTitle: "Seated show",
93
+ seatIds: [a2],
94
+ labels: ["Stalls · Row A · Seat 2"],
95
+ });
96
+ });
97
+
98
+ it("treats a seat the read no longer lists as gone, labelled from the read when the cart has no label", () => {
99
+ const rebound = { ...seating, seats: seating.seats.filter((s) => s.id !== a1) };
100
+ const noLabels = line({
101
+ ticketMeta: { [STALLS_TIER]: { title: "Stalls", price: 40, seatIds: [a1, a2] } },
102
+ });
103
+ const conflict = findSeatConflict(noLabels, rebound);
104
+ expect(conflict?.seatIds).toEqual([a1]);
105
+ // Not in the read either, so the id is the last resort.
106
+ expect(conflict?.labels).toEqual([a1]);
107
+ const stillListed = findSeatConflict(noLabels, buildSeating(buildLayout(), ["Stalls/A/1"]));
108
+ expect(stillListed?.labels).toEqual(["Stalls · Row A · Seat 1"]);
109
+ });
110
+
111
+ it("is null when the event has no map any more: the server decides", () => {
112
+ expect(findSeatConflict(line(), null)).toBeNull();
113
+ expect(findSeatConflict(line(), undefined)).toBeNull();
114
+ });
115
+ });
116
+
117
+ describe("seatsTakenIdsOf", () => {
118
+ const err = (status: number, data: unknown) => ({ response: { status, data } });
119
+
120
+ it("reads the ids off a 409 SEATS_TAKEN", () => {
121
+ expect(seatsTakenIdsOf(err(409, { code: "SEATS_TAKEN", details: { eventSeatIds: [a1] } }))).toEqual([a1]);
122
+ // The code alone, with no ids: still a seat conflict, to be re-read.
123
+ expect(seatsTakenIdsOf(err(409, { code: "SEATS_TAKEN" }))).toEqual([]);
124
+ });
125
+
126
+ it("is null for every other refusal, including another 409", () => {
127
+ expect(seatsTakenIdsOf(err(409, { code: "INVENTORY_HOLD_EXPIRED" }))).toBeNull();
128
+ expect(seatsTakenIdsOf(err(400, { code: "seats_required", details: { eventSeatIds: [a1] } }))).toBeNull();
129
+ expect(seatsTakenIdsOf(new Error("network"))).toBeNull();
130
+ expect(seatsTakenIdsOf(undefined)).toBeNull();
131
+ });
132
+ });
133
+
134
+ describe("seatsTakenConflict", () => {
135
+ it("attributes the named seats to the event whose line holds them", () => {
136
+ const other = line({ eventId: "ev-other", eventSlug: "other", eventTitle: "Other", tickets: { [GA_TIER]: 1 } });
137
+ expect(seatsTakenConflict([other, line()], [a2, "es-unknown"])).toEqual({
138
+ eventId: "ev-seated",
139
+ eventSlug: "seated-show",
140
+ eventTitle: "Seated show",
141
+ seatIds: [a2],
142
+ labels: ["Stalls · Row A · Seat 2"],
143
+ });
144
+ });
145
+
146
+ it("is null when no line holds any of the ids", () => {
147
+ expect(seatsTakenConflict([line()], ["es-unknown"])).toBeNull();
148
+ expect(seatsTakenConflict([line()], [])).toBeNull();
149
+ });
150
+ });
151
+
152
+ describe("pruneSeatsFromTicketItem", () => {
153
+ it("takes the seats out of their tier, shrinking its quantity, and leaves the GA tier alone", () => {
154
+ expect(pruneSeatsFromTicketItem(line(), [a2])).toEqual(
155
+ line({
156
+ tickets: { [STALLS_TIER]: 1, [GA_TIER]: 1 },
157
+ ticketMeta: {
158
+ [STALLS_TIER]: {
159
+ title: "Stalls",
160
+ price: 40,
161
+ seatIds: [a1],
162
+ seats: [{ id: a1, label: "Stalls · Row A · Seat 1" }],
163
+ },
164
+ [GA_TIER]: { title: "Standing", price: 20 },
165
+ },
166
+ }),
167
+ );
168
+ });
169
+
170
+ it("drops a tier left with no seat, and keeps the event while another tier remains", () => {
171
+ const pruned = pruneSeatsFromTicketItem(line(), [a1, a2]);
172
+ expect(pruned?.tickets).toEqual({ [GA_TIER]: 1 });
173
+ expect(pruned?.ticketMeta).toEqual({ [GA_TIER]: { title: "Standing", price: 20 } });
174
+ });
175
+
176
+ it("returns null when nothing is left, so the event leaves the cart", () => {
177
+ const seatedOnly = line({
178
+ tickets: { [STALLS_TIER]: 2 },
179
+ ticketMeta: { [STALLS_TIER]: line().ticketMeta[STALLS_TIER]! },
180
+ });
181
+ expect(pruneSeatsFromTicketItem(seatedOnly, [a1, a2])).toBeNull();
182
+ });
183
+
184
+ it("never substitutes: an id it does not hold changes nothing", () => {
185
+ expect(pruneSeatsFromTicketItem(line(), ["es-unknown"])).toEqual(line());
186
+ });
187
+ });
@@ -140,10 +140,7 @@ describe("useCartResume: an add-on never lands without its ticket", () => {
140
140
  // The basket this used to build is one the server refuses outright: an
141
141
  // add-on with no base. The buyer reached the payment screen, was turned
142
142
  // away, and nothing on the page ever named the poster as the reason.
143
- const { result } = await resume([
144
- ticketLine({ available: false, unavailableReason: "sold_out" }),
145
- addon,
146
- ]);
143
+ const { result } = await resume([ticketLine({ available: false, unavailableReason: "sold_out" }), addon]);
147
144
 
148
145
  // Nothing survived, so nothing is written to the basket at all. Before the
149
146
  // fix this was called with the poster as its only item.
@@ -152,16 +149,11 @@ describe("useCartResume: an add-on never lands without its ticket", () => {
152
149
  });
153
150
 
154
151
  it("tells the buyer the add-on was left out rather than dropping it silently", async () => {
155
- const { result } = await resume([
156
- ticketLine({ available: false, unavailableReason: "sold_out" }),
157
- addon,
158
- ]);
152
+ const { result } = await resume([ticketLine({ available: false, unavailableReason: "sold_out" }), addon]);
159
153
 
160
154
  expect(result.current.droppedLines.map((line) => line.title)).toEqual(["Tour poster"]);
161
155
  // Not smuggled into the server's own list: nothing about it is unavailable.
162
- expect(result.current.unavailableLines.map((line) => line.title)).toEqual([
163
- "General Admission",
164
- ]);
156
+ expect(result.current.unavailableLines.map((line) => line.title)).toEqual(["General Admission"]);
165
157
  });
166
158
 
167
159
  it("leaves an orphaned add-on out of the count and the subtotal", async () => {