@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.
- package/package.json +4 -2
- package/src/_tests/workspaceAliases.ts +1 -0
- package/src/contexts/CartContext.tsx +88 -5
- package/src/data/queries/useCallBreakouts.ts +112 -0
- package/src/data/queries/useCheckouts.ts +15 -2
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +80 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useMyBookings.ts +16 -0
- package/src/data/queries/useNotifications.ts +1 -1
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +153 -0
- package/src/i18n/en.json +153 -0
- package/src/index.ts +1 -0
- package/src/server/_tests/buildPwaHead.spec.ts +1 -1
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
- package/src/server/index.ts +75 -1
- package/src/server/platformEvents.generated.ts +60 -0
- package/src/server/pwa.ts +1 -1
- package/src/types/models.ts +137 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
- package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
- package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
- package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
- package/src/ui/headless/checkout/seatConflict.ts +117 -0
- package/src/ui/headless/checkout/useCheckout.ts +178 -55
- package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
- package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
- package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
- package/src/ui/headless/event/useEventCheckout.ts +362 -22
- package/src/ui/headless/event/useEventSeating.ts +233 -0
- package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
- package/src/ui/headless/forms/useEmailListForm.ts +37 -6
- package/src/ui/headless/forms/usePhoneInput.ts +294 -0
- package/src/ui/headless/forms/useSectionedForm.ts +2 -2
- package/src/ui/headless/index.ts +48 -1
- package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
- package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
- package/src/ui/headless/membership/membershipApplication.ts +109 -0
- package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
- package/src/ui/headless/membership/membershipTrial.ts +53 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
- package/src/ui/index.ts +16 -0
- package/src/ui/media/CallStage.tsx +107 -10
- package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
- package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
- package/src/ui/media/_tests/callState.spec.ts +103 -1
- package/src/ui/media/callBreakouts.ts +57 -0
- package/src/ui/media/callState.ts +83 -23
- package/src/ui/media/index.ts +11 -0
- package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
- package/src/ui/media/panels/CallSidePanel.tsx +37 -4
- package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
- package/src/ui/media/stage/CallControlsBar.tsx +46 -0
- package/src/ui/media/stage/CallTopBar.tsx +1 -1
- package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
- package/src/ui/payment/ForgeStripePayment.tsx +78 -7
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/shell/TribeNestApp.tsx +1 -1
- package/src/ui/styled/AccountDashboard.tsx +165 -2
- package/src/ui/styled/BundleConfirmation.tsx +47 -2
- package/src/ui/styled/Cart.tsx +211 -135
- package/src/ui/styled/Checkout.tsx +163 -32
- package/src/ui/styled/EmailListForm.tsx +7 -9
- package/src/ui/styled/EventTickets.tsx +143 -4
- package/src/ui/styled/LoginForm.tsx +5 -3
- package/src/ui/styled/MembershipCheckout.tsx +596 -256
- package/src/ui/styled/MembershipTierCallout.tsx +17 -3
- package/src/ui/styled/MembershipTiers.tsx +148 -6
- package/src/ui/styled/PhoneInput.tsx +127 -0
- package/src/ui/styled/ProductGrid.tsx +14 -2
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
- package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
- package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
- package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
- package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
- package/src/ui/styled/forge-utilities.css +67 -0
- package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
- package/src/ui/styled/seating/SeatLegend.tsx +73 -0
- package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
- package/src/ui/styled/seating/paint.ts +79 -0
- package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
- package/src/ui/styled/seating/viewport.ts +86 -0
- package/src/utils/_tests/engagementTracker.spec.ts +111 -0
- package/src/utils/engagementTracker.ts +73 -0
- package/src/utils/landing.ts +1 -1
package/src/types/models.ts
CHANGED
|
@@ -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(
|
|
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 () => {
|