@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 +2 -1
- package/src/client/activeFunnel.ts +35 -0
- package/src/client/createForgeClient.ts +9 -0
- package/src/contexts/CartContext.tsx +8 -1
- package/src/data/queries/useForms.ts +48 -3
- package/src/data/queries/usePageActions.ts +2 -2
- package/src/types/models.ts +7 -0
- package/src/ui/headless/checkout/useCheckout.ts +50 -12
- package/src/ui/headless/event/useEventCheckout.ts +3 -0
- package/src/ui/headless/funnel/Funnel.tsx +13 -0
- package/src/ui/styled/BundleConfirmation.tsx +77 -46
- package/src/ui/styled/Checkout.tsx +41 -2
- package/src/ui/styled/EventTickets.tsx +84 -22
- package/src/ui/styled/MusicLinkPage.tsx +290 -137
- package/src/ui/styled/PageActions.tsx +13 -9
- package/src/ui/styled/ProductGrid.tsx +17 -4
- package/src/ui/styled/_tests/musicLinkChrome.spec.ts +48 -0
- package/src/ui/styled/musicLinkStyles.tsx +475 -0
- package/src/ui/styled/musicServiceIcons.ts +26 -0
- package/src/utils/contrast.ts +60 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tribe-nest/forge",
|
|
3
|
-
"version": "3.
|
|
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
|
-
|
|
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 (
|
|
27
|
-
const
|
|
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";
|
package/src/types/models.ts
CHANGED
|
@@ -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,
|
|
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
|
|
215
|
-
//
|
|
216
|
-
// has always had, below.
|
|
217
|
-
if (
|
|
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 =
|
|
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 (
|
|
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,
|
|
567
|
+
}, [couponCode, orderId, checkoutId, finalisePath, applyCouponMutation, applyCheckoutCouponMutation, absorbBundleCoupon, usesCheckoutEngine]);
|
|
538
568
|
|
|
539
569
|
const removeCoupon = useCallback(async () => {
|
|
540
|
-
if (
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
<Body>
|
|
87
|
+
<WarnSeal icon={<XCircle size={28} />} />
|
|
88
|
+
<h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>Payment didn'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
|
-
<
|
|
92
|
-
|
|
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
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
{
|
|
113
|
-
|
|
114
|
-
|
|
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'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
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
</
|
|
152
|
-
|
|
155
|
+
Order confirmed
|
|
156
|
+
</p>
|
|
157
|
+
<h1 style={{ fontSize: 24, fontWeight: 800, margin: "0 0 8px" }}>You'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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|