@tribe-nest/forge 3.57.0 → 3.59.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/useEvents.ts +68 -0
- package/src/data/queries/useMyBookings.ts +16 -0
- package/src/data/queries/useNotifications.ts +1 -1
- package/src/i18n/de.json +79 -0
- package/src/i18n/en.json +79 -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 +1 -1
- package/src/server/pwa.ts +1 -1
- package/src/types/models.ts +10 -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 +25 -1
- package/src/ui/index.ts +7 -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/shell/TribeNestApp.tsx +1 -1
- package/src/ui/styled/AccountDashboard.tsx +5 -0
- 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/PhoneInput.tsx +127 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -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/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/i18n/en.json
CHANGED
|
@@ -229,6 +229,7 @@
|
|
|
229
229
|
"forge.bundle_confirmation.partial_body": "Your payment went through and part of your order is confirmed. We're still finishing the rest. It will arrive by email shortly, with nothing more to pay.",
|
|
230
230
|
"forge.bundle_confirmation.partial_title": "Payment received",
|
|
231
231
|
"forge.bundle_confirmation.return_to_checkout": "Return to checkout",
|
|
232
|
+
"forge.bundle_confirmation.seats_title": "Your seats",
|
|
232
233
|
"forge.bundle_confirmation.summary_both": "Paid in one go. Your tickets are on their way by email, and your order is confirmed.",
|
|
233
234
|
"forge.bundle_confirmation.summary_order": "Paid in one go. Your order is confirmed.",
|
|
234
235
|
"forge.bundle_confirmation.summary_paid": "Paid in one go.",
|
|
@@ -236,6 +237,40 @@
|
|
|
236
237
|
"forge.bundle_confirmation.tickets_emailed": "Tickets emailed to you",
|
|
237
238
|
"forge.bundle_confirmation.title": "You're all set",
|
|
238
239
|
"forge.bundle_confirmation.view_orders": "View your orders",
|
|
240
|
+
"forge.call_stage.breakouts.assign_auto": "Automatically",
|
|
241
|
+
"forge.call_stage.breakouts.assign_label": "Assign people",
|
|
242
|
+
"forge.call_stage.breakouts.assign_manual": "I will assign them",
|
|
243
|
+
"forge.call_stage.breakouts.assigned_not_arrived": "(on the way)",
|
|
244
|
+
"forge.call_stage.breakouts.button": "Rooms",
|
|
245
|
+
"forge.call_stage.breakouts.close_all": "Close all rooms",
|
|
246
|
+
"forge.call_stage.breakouts.close_cancel": "Keep them open",
|
|
247
|
+
"forge.call_stage.breakouts.close_confirm": "Close rooms",
|
|
248
|
+
"forge.call_stage.breakouts.close_confirm_body": "Everyone comes back to the main room and the rooms are closed.",
|
|
249
|
+
"forge.call_stage.breakouts.count_label": "Number of rooms",
|
|
250
|
+
"forge.call_stage.breakouts.create": "Create rooms",
|
|
251
|
+
"forge.call_stage.breakouts.create_title": "Breakout rooms",
|
|
252
|
+
"forge.call_stage.breakouts.current_marker": "You are here",
|
|
253
|
+
"forge.call_stage.breakouts.empty_room": "Nobody here yet",
|
|
254
|
+
"forge.call_stage.breakouts.error": "That did not work. Try again.",
|
|
255
|
+
"forge.call_stage.breakouts.help_attendee": "The host has split this call into smaller rooms. You are moved automatically, your camera and microphone stay as they are, and you come back to the main room when the host closes the rooms or when you press Return to main room.",
|
|
256
|
+
"forge.call_stage.breakouts.help_attendee_label": "What are breakout rooms?",
|
|
257
|
+
"forge.call_stage.breakouts.help_create": "Each room is a separate call under this one. With automatic assignment everyone here is spread evenly across the rooms and moved straight away; with manual assignment nobody moves until you pick a room for them. You can join any room yourself, and everyone comes back when you close the rooms.",
|
|
258
|
+
"forge.call_stage.breakouts.help_create_label": "What happens when I create rooms?",
|
|
259
|
+
"forge.call_stage.breakouts.help_join": "Join moves you into that room; the other rooms carry on without you. Picking a room beside somebody's name moves them, and a recording you start follows the room you are in.",
|
|
260
|
+
"forge.call_stage.breakouts.help_join_label": "How do I move between rooms?",
|
|
261
|
+
"forge.call_stage.breakouts.join": "Join",
|
|
262
|
+
"forge.call_stage.breakouts.loading": "Loading rooms…",
|
|
263
|
+
"forge.call_stage.breakouts.main_room": "Main room",
|
|
264
|
+
"forge.call_stage.breakouts.move_to_label": "Move {name} to a room",
|
|
265
|
+
"forge.call_stage.breakouts.participant_count": "{count} in this room",
|
|
266
|
+
"forge.call_stage.breakouts.return_to_main": "Return to main",
|
|
267
|
+
"forge.call_stage.breakouts.return_to_main_room": "Return to main room",
|
|
268
|
+
"forge.call_stage.breakouts.status_moving": "Moving you to {room}",
|
|
269
|
+
"forge.call_stage.breakouts.status_moving_detail": "Your camera and microphone stay as they are.",
|
|
270
|
+
"forge.call_stage.breakouts.status_returning": "Returning you to the main room",
|
|
271
|
+
"forge.call_stage.breakouts.tab": "Rooms",
|
|
272
|
+
"forge.call_stage.breakouts.you_are_in": "You are in {room}",
|
|
273
|
+
"forge.call_stage.breakouts.you_are_in_main": "You are in the main room",
|
|
239
274
|
"forge.call_stage.camera_start": "Start camera",
|
|
240
275
|
"forge.call_stage.camera_stop": "Stop camera",
|
|
241
276
|
"forge.call_stage.chat": "Chat",
|
|
@@ -353,6 +388,7 @@
|
|
|
353
388
|
"forge.cart.aria_remove_item": "Remove item",
|
|
354
389
|
"forge.cart.aria_remove_tickets": "Remove tickets",
|
|
355
390
|
"forge.cart.attached_to_tickets": "Added with your tickets",
|
|
391
|
+
"forge.cart.change_seats": "Change seats",
|
|
356
392
|
"forge.cart.checkout": "Checkout",
|
|
357
393
|
"forge.cart.empty_title": "Your cart is empty.",
|
|
358
394
|
"forge.cart.gift_for": "Gift for {recipient_name} ({recipient_email})",
|
|
@@ -436,6 +472,10 @@
|
|
|
436
472
|
"forge.checkout.rate_days": "({days} days)",
|
|
437
473
|
"forge.checkout.rate_estimated_day": "Estimated {days} day",
|
|
438
474
|
"forge.checkout.rate_estimated_days": "Estimated {days} days",
|
|
475
|
+
"forge.checkout.seats_conflict_action": "Choose other seats",
|
|
476
|
+
"forge.checkout.seats_conflict_body": "{seats} for {event_title} went to another buyer. Choose other seats to continue; the rest of your cart is unchanged.",
|
|
477
|
+
"forge.checkout.seats_conflict_title": "Some of your seats were just taken",
|
|
478
|
+
"forge.checkout.seats_label": "Seats: {seats}",
|
|
439
479
|
"forge.checkout.shipping": "Shipping",
|
|
440
480
|
"forge.checkout.shipping_address_title": "Shipping Address",
|
|
441
481
|
"forge.checkout.shipping_options_title": "Shipping Options",
|
|
@@ -809,6 +849,17 @@
|
|
|
809
849
|
"forge.event_detail.not_found": "Event not found.",
|
|
810
850
|
"forge.event_detail.presale_only": "Tickets for this event are presale only.",
|
|
811
851
|
"forge.event_detail.price_from_label": "From",
|
|
852
|
+
"forge.event_seat_picker.dismiss": "Dismiss",
|
|
853
|
+
"forge.event_seat_picker.empty": "There are no seats to choose from yet.",
|
|
854
|
+
"forge.event_seat_picker.help_body": "Tap a seat to choose it and tap it again to let it go. Pinch or scroll to zoom, drag to move around the map. Grey seats are not available. Best available picks the lowest seats together for you.",
|
|
855
|
+
"forge.event_seat_picker.help_label": "How do I choose seats?",
|
|
856
|
+
"forge.event_seat_picker.loading": "Loading the seat map",
|
|
857
|
+
"forge.event_seat_picker.max_reached": "You have the most seats allowed on {tier}.",
|
|
858
|
+
"forge.event_seat_picker.mix_blocked": "That seat can't be added to your current selection.",
|
|
859
|
+
"forge.event_seat_picker.seat_unavailable": "That seat is not available.",
|
|
860
|
+
"forge.event_seat_picker.seats_taken_body": "{seats} went to another buyer. Choose again from the seats still shown as available.",
|
|
861
|
+
"forge.event_seat_picker.seats_taken_title": "Some of your seats were just taken",
|
|
862
|
+
"forge.event_seat_picker.title": "Choose your seats",
|
|
812
863
|
"forge.event_series_detail.date_count": "{count} dates",
|
|
813
864
|
"forge.event_series_detail.date_count_range": "{count} dates · {first_date} to {last_date}",
|
|
814
865
|
"forge.event_series_detail.location_online": "Online",
|
|
@@ -883,6 +934,11 @@
|
|
|
883
934
|
"forge.event_tickets.pwyw_range_min_max": "(minimum {min}, up to {max})",
|
|
884
935
|
"forge.event_tickets.remove_discount": "Remove discount",
|
|
885
936
|
"forge.event_tickets.request_tickets": "Request tickets",
|
|
937
|
+
"forge.event_tickets.seat_best_available": "Best available",
|
|
938
|
+
"forge.event_tickets.seat_remove_aria": "Remove {seat}",
|
|
939
|
+
"forge.event_tickets.seats_left_one": "{count} seat left",
|
|
940
|
+
"forge.event_tickets.seats_left_other": "{count} seats left",
|
|
941
|
+
"forge.event_tickets.seats_title": "Your seats",
|
|
886
942
|
"forge.event_tickets.select_option": "Select…",
|
|
887
943
|
"forge.event_tickets.select_title": "Select Tickets",
|
|
888
944
|
"forge.event_tickets.subtotal": "Subtotal",
|
|
@@ -1228,6 +1284,14 @@
|
|
|
1228
1284
|
"forge.payment_link_payment.preparing_payment": "Preparing payment…",
|
|
1229
1285
|
"forge.payment_link_payment.tax_added": "+ {tax_amount} tax",
|
|
1230
1286
|
"forge.payment_link_payment.title": "Payment request",
|
|
1287
|
+
"forge.phone_input.country_label": "Country",
|
|
1288
|
+
"forge.phone_input.country_placeholder": "Select country",
|
|
1289
|
+
"forge.phone_input.error_invalid": "That does not look like a valid number for the country selected",
|
|
1290
|
+
"forge.phone_input.error_no_country": "Please select your country first",
|
|
1291
|
+
"forge.phone_input.error_required": "Please enter a phone number",
|
|
1292
|
+
"forge.phone_input.hint_no_country_code": "You can leave out the country code. The flag sets it for you.",
|
|
1293
|
+
"forge.phone_input.placeholder_fallback": "Phone number",
|
|
1294
|
+
"forge.phone_input.warning_landline": "This looks like a landline, so it may not receive text messages.",
|
|
1231
1295
|
"forge.podcast_episode.next": "Next →",
|
|
1232
1296
|
"forge.podcast_episode.not_found": "Episode not found.",
|
|
1233
1297
|
"forge.podcast_episode.pause": "Pause",
|
|
@@ -1358,6 +1422,21 @@
|
|
|
1358
1422
|
"forge.review_form.title_label": "Title",
|
|
1359
1423
|
"forge.review_form.title_placeholder": "Sum it up in a few words",
|
|
1360
1424
|
"forge.review_form.update_submit": "Update review",
|
|
1425
|
+
"forge.seat_legend.aria_label": "Map key",
|
|
1426
|
+
"forge.seat_legend.left_one": "{count} left",
|
|
1427
|
+
"forge.seat_legend.left_other": "{count} left",
|
|
1428
|
+
"forge.seat_legend.selected": "Selected",
|
|
1429
|
+
"forge.seat_legend.sold_out": "Sold out",
|
|
1430
|
+
"forge.seat_legend.unavailable": "Unavailable",
|
|
1431
|
+
"forge.seat_map_canvas.accessible": "accessible seat",
|
|
1432
|
+
"forge.seat_map_canvas.aria_label": "Seat map",
|
|
1433
|
+
"forge.seat_map_canvas.fit": "Fit the whole map",
|
|
1434
|
+
"forge.seat_map_canvas.general_admission": "General admission",
|
|
1435
|
+
"forge.seat_map_canvas.status_available": "available",
|
|
1436
|
+
"forge.seat_map_canvas.status_selected": "selected",
|
|
1437
|
+
"forge.seat_map_canvas.status_unavailable": "unavailable",
|
|
1438
|
+
"forge.seat_map_canvas.zoom_in": "Zoom in",
|
|
1439
|
+
"forge.seat_map_canvas.zoom_out": "Zoom out",
|
|
1361
1440
|
"forge.signup_form.accept_terms": "I accept the Terms and Privacy Policy",
|
|
1362
1441
|
"forge.signup_form.code_label": "Verification code",
|
|
1363
1442
|
"forge.signup_form.code_sent": "We emailed a 6-digit code to {email}. Enter it to finish creating your account.",
|
package/src/index.ts
CHANGED
|
@@ -148,6 +148,7 @@ export * from "./data/queries/useWebsiteAgent";
|
|
|
148
148
|
export * from "./data/queries/useCurrencies";
|
|
149
149
|
export * from "./data/queries/useChat";
|
|
150
150
|
export * from "./data/queries/useCallChat";
|
|
151
|
+
export * from "./data/queries/useCallBreakouts";
|
|
151
152
|
export * from "./data/queries/useAnalytics";
|
|
152
153
|
export * from "./data/queries/useCommunity";
|
|
153
154
|
export * from "./data/queries/useReviews";
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// - null/undefined siteConfig degradation (never throws)
|
|
12
12
|
// - the known client bug this fixes: apple-touch-icon is always emitted when an icon exists
|
|
13
13
|
//
|
|
14
|
-
// Source: packages/forge/src/server/pwa.ts. Spec: docs/pwa-custom-code-initiative.md.
|
|
14
|
+
// Source: packages/forge/src/server/pwa.ts. Spec: docs/archive/shipped/pwa-custom-code-initiative.md.
|
|
15
15
|
|
|
16
16
|
import { describe, it, expect } from "vitest";
|
|
17
17
|
import { buildPwaHead } from "../pwa";
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// - overrides (shortcuts) merge + precedence
|
|
12
12
|
// - determinism / idempotence (same input → identical output twice)
|
|
13
13
|
//
|
|
14
|
-
// Source: packages/forge/src/server/pwa.ts. Spec: docs/pwa-custom-code-initiative.md.
|
|
14
|
+
// Source: packages/forge/src/server/pwa.ts. Spec: docs/archive/shipped/pwa-custom-code-initiative.md.
|
|
15
15
|
|
|
16
16
|
import { describe, it, expect } from "vitest";
|
|
17
17
|
import { buildWebManifest, type BuildWebManifestOptions } from "../pwa";
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// ({ title, body, type, url, profileId, channelId, spaceId, icon }) matches the
|
|
14
14
|
// client SW, plus offline-fallback + asset-cache + skipWaiting/clients.claim.
|
|
15
15
|
//
|
|
16
|
-
// Source: packages/forge/src/server/pwa.ts. Spec: docs/pwa-custom-code-initiative.md.
|
|
16
|
+
// Source: packages/forge/src/server/pwa.ts. Spec: docs/archive/shipped/pwa-custom-code-initiative.md.
|
|
17
17
|
|
|
18
18
|
import { describe, it, expect, vi } from "vitest";
|
|
19
19
|
import { forgeServiceWorkerScript, FORGE_PWA_VERSION } from "../pwa";
|
package/src/server/index.ts
CHANGED
|
@@ -641,7 +641,7 @@ export function toPlainText(html?: string, max = 200): string | undefined {
|
|
|
641
641
|
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
|
|
642
642
|
}
|
|
643
643
|
|
|
644
|
-
// Platform access + events (docs/app-platform-access-initiative.md). SERVER ONLY —
|
|
644
|
+
// Platform access + events (docs/archive/shipped/app-platform-access-initiative.md). SERVER ONLY —
|
|
645
645
|
// the client carries the app's platform credential.
|
|
646
646
|
export {
|
|
647
647
|
createPlatformClient,
|
package/src/server/pwa.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// only three thin route shells (`/manifest.webmanifest`, `/sw.js`, `/offline`)
|
|
9
9
|
// that call these functions.
|
|
10
10
|
//
|
|
11
|
-
// See docs/pwa-custom-code-initiative.md (locked decisions).
|
|
11
|
+
// See docs/archive/shipped/pwa-custom-code-initiative.md (locked decisions).
|
|
12
12
|
|
|
13
13
|
import type { ContentDocument } from "../content/types";
|
|
14
14
|
import type { SiteConfig } from "../data/queries/useWebsite";
|
package/src/types/models.ts
CHANGED
|
@@ -1297,6 +1297,16 @@ export interface IEvent {
|
|
|
1297
1297
|
* than trusting its absence.
|
|
1298
1298
|
*/
|
|
1299
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;
|
|
1300
1310
|
media: IMedia[];
|
|
1301
1311
|
slug: string;
|
|
1302
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 () => {
|