@tribe-nest/forge 3.24.0 → 3.26.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.24.0",
3
+ "version": "3.26.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -39,6 +39,7 @@
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/react": "^19.1.2",
42
+ "simple-icons": "^16.28.0",
42
43
  "typescript": "~5.8.3"
43
44
  },
44
45
  "license": "ISC"
@@ -0,0 +1,35 @@
1
+ // Which funnel the visitor is currently inside, held outside React so the
2
+ // shared axios client can read it.
3
+ //
4
+ // Why a module singleton rather than a prop or a hook argument: a funnel's UI
5
+ // is written per site, increasingly by an agent, and the pages that CONVERT (a
6
+ // booking, a checkout, an email-list join) are ordinary Forge components that
7
+ // know nothing about funnels. Any design where each capture call has to pass
8
+ // the funnel produces attribution that is missing on whichever call someone
9
+ // forgot, which reads as complete and undercounts in silence. Put it in the
10
+ // transport and no call site can omit it.
11
+ //
12
+ // Scope: one active funnel at a time. Funnels are page-level flows and nesting
13
+ // them has no meaning, so a nested <Funnel> replacing its parent is the honest
14
+ // behaviour rather than a merge.
15
+
16
+ let activeFunnelId: string | null = null;
17
+
18
+ /** Mark this funnel as the one the visitor is currently in. */
19
+ export function setActiveFunnel(funnelId: string | null): void {
20
+ activeFunnelId = funnelId;
21
+ }
22
+
23
+ /**
24
+ * The active funnel id, or null outside a funnel.
25
+ *
26
+ * Deliberately NOT the funnel session id. Attribution answers "which funnel
27
+ * brought in this lead", and the funnel id alone answers it. The session id is
28
+ * a behavioural key, so attaching it to a named person is the thing consent
29
+ * regimes exist for, and it is also the one value that vanishes in private mode
30
+ * (`getFunnelSessionId` returns null), which would make attribution degrade
31
+ * exactly where the funnel id does not.
32
+ */
33
+ export function getActiveFunnelId(): string | null {
34
+ return activeFunnelId;
35
+ }
@@ -1,4 +1,5 @@
1
1
  import axios, { type AxiosError, type AxiosInstance } from "axios";
2
+ import { getActiveFunnelId } from "./activeFunnel";
2
3
 
3
4
  /**
4
5
  * Access-token refresh (backend §7 cutover). Access tokens live ~15 minutes;
@@ -60,6 +61,14 @@ export const createForgeClient = ({
60
61
  config.headers["x-forge-key"] = publishableKey;
61
62
  }
62
63
 
64
+ // First-touch funnel attribution. Rides every request rather than every
65
+ // capture call, so a lead that arrives through a booking or a checkout is
66
+ // attributed the same as one that arrives through a form.
67
+ const funnelId = getActiveFunnelId();
68
+ if (funnelId) {
69
+ config.headers["x-funnel-id"] = funnelId;
70
+ }
71
+
63
72
  const token = getToken?.();
64
73
  if (token) {
65
74
  config.headers["authorization"] = `Bearer ${token}`;
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import type { ProductDeliveryType } from "../types/models";
2
+ import type { IBookingFee, ProductDeliveryType } from "../types/models";
3
3
  import { createContext, useCallback, useContext, useMemo, useState } from "react";
4
4
  import type { ReactNode } from "react";
5
5
  import { useEffect } from "react";
@@ -92,6 +92,13 @@ export type TicketCartItem = {
92
92
  * chosen amount has to arrive in a field the server actually reads.
93
93
  */
94
94
  ticketMeta: Record<string, { title: string; price: number; pwywAmount?: number }>;
95
+ /**
96
+ * The event's RESOLVED booking fee, captured when the modal put the tickets
97
+ * here, so the cart checkout can show the fee line the buyer will be charged
98
+ * (an estimate until start-payment answers; the server figure then wins).
99
+ * Absent on carts saved before the field existed, which renders as no line.
100
+ */
101
+ bookingFee?: IBookingFee | null;
95
102
  };
96
103
 
97
104
  interface CartContextType {
@@ -1,3 +1,4 @@
1
+ import { useRef } from "react";
1
2
  import type { FormData, FormAnswerEntry } from "../../types/models";
2
3
  import { useForge } from "../../provider/ForgeProvider";
3
4
  import { useMutation, useQuery } from "@tanstack/react-query";
@@ -18,14 +19,58 @@ export function usePublicForm(formId?: string) {
18
19
  });
19
20
  }
20
21
 
21
- /** Submit answers to a public form. */
22
+ export interface SubmitPublicFormInput {
23
+ answers: FormAnswerEntry[];
24
+ /**
25
+ * Send this whenever the form collects an email.
26
+ *
27
+ * It is what resolves the submission to a CRM contact, and therefore what
28
+ * makes the lead attributable to the funnel at all. A submission without it
29
+ * is stored, but anonymous: no contact, no attribution, and no automation,
30
+ * because the form's automations are contact-scoped.
31
+ */
32
+ respondentEmail?: string;
33
+ respondentName?: string;
34
+ metadata?: Record<string, unknown>;
35
+ }
36
+
37
+ function newIdempotencyKey(): string {
38
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
39
+ return `fk_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e9).toString(36)}`;
40
+ }
41
+
42
+ /**
43
+ * Submit a public form.
44
+ *
45
+ * Accepts either a bare answers array or the full input. The full form is what
46
+ * a funnel step wants, because `respondentEmail` is what turns a submission
47
+ * into a lead.
48
+ *
49
+ * **Idempotency.** A key is minted per mount and rotated after each successful
50
+ * submit. A double-click or a network retry therefore reuses it and replays the
51
+ * first submission instead of writing a second row or re-firing the form's
52
+ * automations. Going back and deliberately submitting again is a new mount, so
53
+ * a new key and a genuine second submission. Nothing is ever overwritten: a
54
+ * submission can be the record of what somebody agreed to.
55
+ */
22
56
  export function useSubmitPublicForm(formId?: string) {
23
57
  const { client } = useForge();
58
+ const keyRef = useRef<string>(newIdempotencyKey());
24
59
 
25
60
  return useMutation({
26
- mutationFn: async (answers: FormAnswerEntry[]) => {
27
- const res = await client.post(`/public/forms/${formId}/submit`, { answers });
61
+ mutationFn: async (input: FormAnswerEntry[] | SubmitPublicFormInput) => {
62
+ const payload = Array.isArray(input) ? { answers: input } : input;
63
+ const res = await client.post(`/public/forms/${formId}/submit`, {
64
+ ...payload,
65
+ idempotencyKey: keyRef.current,
66
+ });
28
67
  return res.data;
29
68
  },
69
+ // Rotate only once the write has landed. A failed submit keeps the key so
70
+ // the retry is recognised as the same act; a successful one starts a fresh
71
+ // act, so a form left mounted for corrections records each as its own.
72
+ onSuccess: () => {
73
+ keyRef.current = newIdempotencyKey();
74
+ },
30
75
  });
31
76
  }
@@ -5,9 +5,9 @@ export type PageActionType =
5
5
  | "newsletter"
6
6
  | "lead_magnet"
7
7
  | "offer"
8
+ // Products on a page. `config.isAddon` decides whether they can be bought on
9
+ // their own or only alongside what the page is selling.
8
10
  | "product_cards"
9
- // Specific products sold as attachments to the page's own entity.
10
- | "addons"
11
11
  | "donation"
12
12
  | "membership"
13
13
  | "button";
@@ -1893,6 +1893,11 @@ export interface IMusicLinkDestination {
1893
1893
 
1894
1894
  /** Per-link presentation, layered over the site theme by `MusicLinkPage`. */
1895
1895
  export interface IMusicLinkTheme {
1896
+ /**
1897
+ * Which of the five layouts to render. Composition, not colour: the tokens
1898
+ * below tune whichever one is chosen.
1899
+ */
1900
+ style?: "classic" | "immersive" | "split" | "minimal" | "compact";
1896
1901
  mode?: "light" | "dark";
1897
1902
  background?: string;
1898
1903
  text?: string;
@@ -1911,6 +1916,8 @@ export interface IMusicLink {
1911
1916
  artistName: string | null;
1912
1917
  description: string | null;
1913
1918
  artworkUrl: string | null;
1919
+ /** 30-second preview, played over the artwork. Apple's, which does not expire. */
1920
+ previewUrl: string | null;
1914
1921
  releaseDate: string | null;
1915
1922
  theme: IMusicLinkTheme | null;
1916
1923
  destinations: IMusicLinkDestination[];
@@ -23,6 +23,7 @@ import { holdExpiredMessage, isHoldExpiredError } from "./inventoryHold";
23
23
  import { useInventoryHold } from "./useInventoryHold";
24
24
  import { readAttributionRef } from "../../../utils/attribution";
25
25
  import { readLanding } from "../../../utils/landing";
26
+ import { computeBookingFeeAmount } from "../../format/bookingFee";
26
27
  import { ProductDeliveryType, PaymentProviderName, type ApiError, type PublicTaxQuote } from "../../../types/models";
27
28
 
28
29
  /** The four checkout stages (guest details are skipped for a logged-in buyer;
@@ -114,7 +115,15 @@ function messageOf(e: unknown): string | undefined {
114
115
  export function useCheckout(opts: UseCheckoutOptions = {}) {
115
116
  const finalisePath = opts.finalisePath ?? "/checkout/finalise";
116
117
  const { user } = usePublicAuth();
117
- const { cartItems, ticketItems, isBundle, isReady: isCartReady } = useCart();
118
+ const { cartItems, ticketItems, isReady: isCartReady } = useCart();
119
+ /**
120
+ * Any cart holding tickets settles through the checkout (bundle) engine,
121
+ * even with NO products beside them. Tickets have no single-surface order
122
+ * path from this page: `createOrder` only carries `cartItems`, so routing a
123
+ * tickets-only cart there minted an order with a total and no lines. (The
124
+ * event modal's own direct flow is unaffected; it never comes through here.)
125
+ */
126
+ const usesCheckoutEngine = ticketItems.length > 0;
118
127
  const { data: countries = [] } = useShippingCountries();
119
128
  const shippingRates = useShippingRates();
120
129
  const createOrder = useCreateOrder();
@@ -145,6 +154,27 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
145
154
  [cartItems, ticketItems],
146
155
  );
147
156
 
157
+ /**
158
+ * The booking fee the ticket lines will be charged, summed per event from the
159
+ * resolved fee each selection carried out of the modal. An ESTIMATE with the
160
+ * same honesty contract as the modal's own line: it keeps the running total
161
+ * explained while no charge exists, and the server's charged total supersedes
162
+ * it the moment start-payment answers.
163
+ */
164
+ const bookingFeeCost = useMemo(
165
+ () =>
166
+ round2(
167
+ ticketItems.reduce((sum, t) => {
168
+ const ticketSubtotal = Object.entries(t.tickets).reduce(
169
+ (acc, [id, qty]) => acc + (t.ticketMeta[id]?.price ?? 0) * qty,
170
+ 0,
171
+ );
172
+ return sum + computeBookingFeeAmount({ subtotal: ticketSubtotal, fee: t.bookingFee });
173
+ }, 0),
174
+ ),
175
+ [ticketItems],
176
+ );
177
+
148
178
  const [currentStage, setCurrentStage] = useState<CheckoutStage>("userDetails");
149
179
  const [guestUserData, setGuestUserData] = useState<GuestUserData | null>(null);
150
180
  const [shippingData, setShippingData] = useState<CheckoutShippingData>();
@@ -211,10 +241,10 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
211
241
  if (currentStage !== "payment" || !isPaidCheckout || startedRef.current) return;
212
242
  startedRef.current = true;
213
243
 
214
- // A cart spanning tickets AND products goes through the bundle engine so
215
- // both settle on one payment. Every single-surface cart keeps the flow it
216
- // has always had, below.
217
- if (isBundle) {
244
+ // A cart holding tickets (with or without products) goes through the
245
+ // bundle engine so everything settles on one payment. A products-only
246
+ // cart keeps the flow it has always had, below.
247
+ if (usesCheckoutEngine) {
218
248
  const enteredCode = couponCode.trim() || undefined;
219
249
  createCheckout
220
250
  .mutateAsync({
@@ -457,7 +487,7 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
457
487
  * the checkout was created — which for a signed-in buyer with a digital-only
458
488
  * cart was on mount, so the code could never be typed at all.
459
489
  */
460
- const isCouponEditable = isBundle ? !settledCheckoutId : !!orderId;
490
+ const isCouponEditable = usesCheckoutEngine ? !settledCheckoutId : !!orderId;
461
491
 
462
492
  /** Fold an apply/remove answer for a bundle back into the summary + payment. */
463
493
  const absorbBundleCoupon = useCallback((data: ApplyCheckoutCouponResult) => {
@@ -479,7 +509,7 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
479
509
  }, []);
480
510
 
481
511
  const applyCoupon = useCallback(async () => {
482
- if (isBundle) {
512
+ if (usesCheckoutEngine) {
483
513
  if (!couponCode.trim()) return;
484
514
  setCouponError("");
485
515
  // Not created yet (a guest, or a cart with a shipping stage still ahead):
@@ -534,10 +564,10 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
534
564
  } finally {
535
565
  setIsApplyingCoupon(false);
536
566
  }
537
- }, [couponCode, orderId, checkoutId, finalisePath, applyCouponMutation, applyCheckoutCouponMutation, absorbBundleCoupon, isBundle]);
567
+ }, [couponCode, orderId, checkoutId, finalisePath, applyCouponMutation, applyCheckoutCouponMutation, absorbBundleCoupon, usesCheckoutEngine]);
538
568
 
539
569
  const removeCoupon = useCallback(async () => {
540
- if (isBundle) {
570
+ if (usesCheckoutEngine) {
541
571
  setCouponError("");
542
572
  // Nothing created yet — the staged code simply never travels.
543
573
  if (!checkoutId) {
@@ -592,7 +622,7 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
592
622
  } finally {
593
623
  setIsApplyingCoupon(false);
594
624
  }
595
- }, [orderId, checkoutId, finalisePath, applyCouponMutation, applyCheckoutCouponMutation, absorbBundleCoupon, isBundle]);
625
+ }, [orderId, checkoutId, finalisePath, applyCouponMutation, applyCheckoutCouponMutation, absorbBundleCoupon, usesCheckoutEngine]);
596
626
 
597
627
  // ── Free checkout (nothing to charge) ───────────────────────────────────────
598
628
  /** Create the (free) order and return its id so the caller can navigate to the
@@ -660,6 +690,8 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
660
690
  isCartReady,
661
691
  subTotal,
662
692
  shippingCost,
693
+ /** Estimated booking fee across the cart's ticket lines; 0 with no tickets. */
694
+ bookingFeeCost,
663
695
  discountAmount,
664
696
  hasPhysicalProduct,
665
697
  isPaidCheckout,
@@ -695,7 +727,9 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
695
727
  * page instead of waiting for a payment that will never be asked for.
696
728
  */
697
729
  settledCheckoutId,
698
- isBundle,
730
+ /** True when this checkout settles through the bundle engine (any cart
731
+ holding tickets), which is what the coupon field and summary key off. */
732
+ isBundle: usesCheckoutEngine,
699
733
  ticketItems,
700
734
  isCreatingOrder: createOrder.isPending || flow.isStarting,
701
735
  startError,
@@ -731,7 +765,11 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
731
765
  paymentId: effectivePayment?.paymentId,
732
766
  chargedAmount: effectivePayment?.chargedAmount ?? 0,
733
767
  chargedCurrency: effectivePayment?.chargedCurrency ?? "",
734
- returnUrl: `${typeof window !== "undefined" ? window.location.origin : ""}${finalisePath}?orderId=${orderId || ""}`,
768
+ // The SAME bundle-aware URL handed to start-payment. This was previously
769
+ // rebuilt here from `orderId` alone, so a bundle (which has a checkoutId
770
+ // and no order) confirmed its Stripe payment with `?orderId=` empty and
771
+ // landed on a finalise page that could not resolve anything.
772
+ returnUrl,
735
773
  // ── Free checkout ──────────────────────────────────────────────────────────
736
774
  isFreeCheckoutLoading,
737
775
  freeCheckout,
@@ -264,6 +264,9 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
264
264
  eventSlug: slug ?? event.id,
265
265
  eventTitle: event.title,
266
266
  coverImage: event.media?.find((m: { type: string }) => m.type === "image")?.url,
267
+ // The resolved fee travels with the selection so the CART checkout can
268
+ // show the booking-fee line this modal already shows.
269
+ bookingFee: event.bookingFee ?? null,
267
270
  tickets: { ...selectedTickets },
268
271
  ticketMeta: Object.fromEntries(
269
272
  event.tickets
@@ -1,4 +1,5 @@
1
1
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, type ReactNode } from "react";
2
+ import { setActiveFunnel } from "../../../client/activeFunnel";
2
3
  import { useTrackEvent } from "../../../data/queries/useAnalytics";
3
4
  import { useCookieConsent } from "../consent/useCookieConsent";
4
5
  import { getFunnelSessionId, markOnce, resetFunnelSession } from "./funnelSession";
@@ -114,6 +115,18 @@ export function Funnel({ id, steps, name, enabled = true, requireConsent = true,
114
115
  const emitRef = useRef(emit);
115
116
  emitRef.current = emit;
116
117
 
118
+ // Attribution is registered on `enabled`, NOT on `on`. Analytics is what
119
+ // cookie consent governs; "this lead came from this funnel" is first-party
120
+ // business data about a conversion the visitor deliberately started, and
121
+ // gating it behind performance consent would silently drop the attribution of
122
+ // every lead who declined cookies. Nothing behavioural is attached to the
123
+ // person: the funnel id travels, the funnel SESSION id never does.
124
+ useEffect(() => {
125
+ if (!enabled) return;
126
+ setActiveFunnel(id);
127
+ return () => setActiveFunnel(null);
128
+ }, [enabled, id]);
129
+
117
130
  useEffect(() => {
118
131
  if (!on) return;
119
132
  if (!markOnce(id, "entered")) return;
@@ -1,10 +1,17 @@
1
- import { useEffect } from "react";
1
+ import { useEffect, type ReactNode } from "react";
2
2
  import { Clock, Loader2, Ticket, XCircle } from "lucide-react";
3
3
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
4
  import { readableTextOn } from "../theme/contrast";
5
5
  import { ConfirmationStage, ConfirmationCard, CheckSeal, WarnSeal, alpha } from "./Confirmation";
6
6
  import { useCheckoutFinalize } from "../../data/queries/useFinalize";
7
7
  import { useCart } from "../../contexts/CartContext";
8
+ import { usePublicAuth } from "../../contexts/PublicAuthContext";
9
+
10
+ /** The card's padded, centered interior — the shell itself has neither, and
11
+ content dropped in bare sits left-aligned against the card edge. */
12
+ function Body({ children }: { children: ReactNode }) {
13
+ return <div style={{ padding: "40px 32px", textAlign: "center" }}>{children}</div>;
14
+ }
8
15
 
9
16
  export interface BundleConfirmationProps {
10
17
  checkoutId: string;
@@ -32,6 +39,9 @@ export function BundleConfirmation({
32
39
  }: BundleConfirmationProps) {
33
40
  const theme = useForgeTheme();
34
41
  const { clearCart } = useCart();
42
+ // A guest has no account screen behind "View your orders": the link would
43
+ // land them on a login wall for an order their EMAIL is the receipt for.
44
+ const { user } = usePublicAuth();
35
45
  const failedRedirect = redirectStatus === "failed";
36
46
 
37
47
  const { data, isLoading, isError } = useCheckoutFinalize(
@@ -73,12 +83,14 @@ export function BundleConfirmation({
73
83
  return (
74
84
  <ConfirmationStage>
75
85
  <ConfirmationCard>
76
- <WarnSeal icon={<XCircle size={28} />} />
77
- <h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>Payment didn&apos;t go through</h1>
78
- <p style={{ opacity: 0.75, marginBottom: 20 }}>
79
- Nothing was charged and your cart is still here. You can try again.
80
- </p>
81
- {link(checkoutPath, "Return to checkout", true)}
86
+ <Body>
87
+ <WarnSeal icon={<XCircle size={28} />} />
88
+ <h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>Payment didn&apos;t go through</h1>
89
+ <p style={{ opacity: 0.75, marginBottom: 20 }}>
90
+ Nothing was charged and your cart is still here. You can try again.
91
+ </p>
92
+ {link(checkoutPath, "Return to checkout", true)}
93
+ </Body>
82
94
  </ConfirmationCard>
83
95
  </ConfirmationStage>
84
96
  );
@@ -88,8 +100,11 @@ export function BundleConfirmation({
88
100
  return (
89
101
  <ConfirmationStage>
90
102
  <ConfirmationCard>
91
- <Loader2 size={28} style={{ animation: "spin 1s linear infinite" }} />
92
- <h1 style={{ fontSize: 20, fontWeight: 700, marginTop: 12 }}>Confirming your order…</h1>
103
+ <Body>
104
+ <Loader2 size={28} style={{ animation: "cc-spin 1s linear infinite" }} />
105
+ <style>{"@keyframes cc-spin{to{transform:rotate(360deg)}}"}</style>
106
+ <h1 style={{ fontSize: 20, fontWeight: 700, marginTop: 12 }}>Confirming your order…</h1>
107
+ </Body>
93
108
  </ConfirmationCard>
94
109
  </ConfirmationStage>
95
110
  );
@@ -102,16 +117,18 @@ export function BundleConfirmation({
102
117
  return (
103
118
  <ConfirmationStage>
104
119
  <ConfirmationCard>
105
- <WarnSeal icon={<Clock size={28} />} />
106
- <h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>Payment received</h1>
107
- <p style={{ opacity: 0.75, marginBottom: 20 }}>
108
- Your payment went through and part of your order is confirmed. We&apos;re still finishing the rest. It
109
- will arrive by email shortly, with nothing more to pay.
110
- </p>
111
- <div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
112
- {link(accountPath, "View your orders", true)}
113
- {link(explorePath, "Continue shopping")}
114
- </div>
120
+ <Body>
121
+ <WarnSeal icon={<Clock size={28} />} />
122
+ <h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>Payment received</h1>
123
+ <p style={{ opacity: 0.75, marginBottom: 20 }}>
124
+ Your payment went through and part of your order is confirmed. We&apos;re still finishing the rest. It
125
+ will arrive by email shortly, with nothing more to pay.
126
+ </p>
127
+ <div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
128
+ {user && link(accountPath, "View your orders", true)}
129
+ {link(explorePath, "Continue shopping", !user)}
130
+ </div>
131
+ </Body>
115
132
  </ConfirmationCard>
116
133
  </ConfirmationStage>
117
134
  );
@@ -123,38 +140,52 @@ export function BundleConfirmation({
123
140
  return (
124
141
  <ConfirmationStage>
125
142
  <ConfirmationCard>
126
- <CheckSeal />
127
- <h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>You&apos;re all set</h1>
128
- <p style={{ opacity: 0.75, marginBottom: 16 }}>
129
- Paid in one go. {ticketChildren > 0 && "Your tickets are on their way by email"}
130
- {ticketChildren > 0 && orderChildren > 0 && ", and "}
131
- {orderChildren > 0 && `${ticketChildren > 0 ? "your order is confirmed" : "Your order is confirmed"}`}.
132
- </p>
133
-
134
- {ticketChildren > 0 && (
135
- <div
143
+ <Body>
144
+ <CheckSeal />
145
+ <p
136
146
  style={{
137
- display: "inline-flex",
138
- alignItems: "center",
139
- gap: 8,
140
- padding: "8px 14px",
141
- borderRadius: theme.cornerRadius,
142
- background: alpha(theme.colors.primary, 0.08),
147
+ margin: "22px 0 6px",
148
+ fontSize: 12,
149
+ fontWeight: 700,
150
+ letterSpacing: "0.22em",
151
+ textTransform: "uppercase",
143
152
  color: theme.colors.primary,
144
- fontSize: 14,
145
- fontWeight: 600,
146
- marginBottom: 20,
147
153
  }}
148
154
  >
149
- <Ticket size={16} />
150
- Tickets emailed to you
151
- </div>
152
- )}
155
+ Order confirmed
156
+ </p>
157
+ <h1 style={{ fontSize: 24, fontWeight: 800, margin: "0 0 8px" }}>You&apos;re all set</h1>
158
+ <p style={{ opacity: 0.75, margin: "0 auto 16px", maxWidth: 360, lineHeight: 1.55 }}>
159
+ Paid in one go. {ticketChildren > 0 && "Your tickets are on their way by email"}
160
+ {ticketChildren > 0 && orderChildren > 0 && ", and "}
161
+ {orderChildren > 0 && `${ticketChildren > 0 ? "your order is confirmed" : "Your order is confirmed"}`}.
162
+ </p>
153
163
 
154
- <div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
155
- {link(accountPath, "View your orders", true)}
156
- {link(explorePath, "Continue shopping")}
157
- </div>
164
+ {ticketChildren > 0 && (
165
+ <div
166
+ style={{
167
+ display: "inline-flex",
168
+ alignItems: "center",
169
+ gap: 8,
170
+ padding: "8px 14px",
171
+ borderRadius: theme.cornerRadius,
172
+ background: alpha(theme.colors.primary, 0.08),
173
+ color: theme.colors.primary,
174
+ fontSize: 14,
175
+ fontWeight: 600,
176
+ marginBottom: 20,
177
+ }}
178
+ >
179
+ <Ticket size={16} />
180
+ Tickets emailed to you
181
+ </div>
182
+ )}
183
+
184
+ <div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
185
+ {user && link(accountPath, "View your orders", true)}
186
+ {link(explorePath, "Continue shopping", !user)}
187
+ </div>
188
+ </Body>
158
189
  </ConfirmationCard>
159
190
  </ConfirmationStage>
160
191
  );
@@ -177,7 +177,9 @@ export function Checkout({
177
177
  return <Loading fullPage />;
178
178
  }
179
179
 
180
- if (c.cartItems.length === 0) {
180
+ // Tickets count: a cart holding only tickets (buyer skipped the add-ons)
181
+ // is NOT empty, and this page is the only checkout it has.
182
+ if (c.cartItems.length === 0 && c.ticketItems.length === 0) {
181
183
  return (
182
184
  <div
183
185
  className={["w-full max-w-4xl mx-auto p-6", className].filter(Boolean).join(" ")}
@@ -622,6 +624,34 @@ export function Checkout({
622
624
  Items
623
625
  </h4>
624
626
  <div className="space-y-2">
627
+ {/* Ticket lines first: they are what the bundle is FOR, and the
628
+ subtotal already includes them, so a summary without them
629
+ reads as a wrong total. */}
630
+ {c.ticketItems.flatMap((t) =>
631
+ Object.entries(t.tickets)
632
+ .filter(([, qty]) => qty > 0)
633
+ .map(([ticketId, qty]) => {
634
+ const meta = t.ticketMeta[ticketId];
635
+ return (
636
+ <div key={`${t.eventId}-${ticketId}`} className="flex justify-between text-sm" data-testid="summary-ticket-line">
637
+ <div className="flex-1">
638
+ <p className="font-medium" style={{ color: theme.colors.text }}>
639
+ {meta?.title ?? "Ticket"}
640
+ </p>
641
+ <p className="text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
642
+ {t.eventTitle}
643
+ </p>
644
+ <p className="text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
645
+ Qty: {qty}
646
+ </p>
647
+ </div>
648
+ <p className="font-medium" style={{ color: theme.colors.text }}>
649
+ {formatCurrency((meta?.price ?? 0) * qty)}
650
+ </p>
651
+ </div>
652
+ );
653
+ }),
654
+ )}
625
655
  {c.cartItems.map((item) => (
626
656
  <div
627
657
  key={`${item.productId}-${item.productVariantId}-${item.isGift}-${item.recipientEmail}`}
@@ -756,6 +786,15 @@ export function Checkout({
756
786
  <span style={{ color: theme.colors.text }}>{formatCurrency(c.shippingCost)}</span>
757
787
  </div>
758
788
  )}
789
+ {/* The ticket booking fee, named BEFORE the buyer commits. It is
790
+ charged either way; omitting the line here left a total that
791
+ jumped past the subtotal with no explanation. */}
792
+ {c.bookingFeeCost > 0 && (
793
+ <div className="flex justify-between" data-testid="summary-booking-fee">
794
+ <span style={{ color: theme.colors.text }}>Booking fee</span>
795
+ <span style={{ color: theme.colors.text }}>{formatCurrency(c.bookingFeeCost)}</span>
796
+ </div>
797
+ )}
759
798
  {/* Exclusive mode: the authoritative tax line, once the checkout
760
799
  quote is known (added on top of the total). */}
761
800
  {taxSummary?.mode === "exclusive" && (
@@ -772,7 +811,7 @@ export function Checkout({
772
811
  <span>
773
812
  {c.chargedTotal
774
813
  ? formatCurrency(c.chargedTotal.amount, c.chargedTotal.currency)
775
- : formatCurrency(c.subTotal + c.shippingCost)}
814
+ : formatCurrency(c.subTotal + c.shippingCost + c.bookingFeeCost)}
776
815
  </span>
777
816
  </div>
778
817
  {/* Inclusive mode: the total already contains tax — break out the