@tribe-nest/forge 3.2.0 → 3.4.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 +1 -1
- package/src/data/queries/useCheckouts.ts +84 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -3
- package/src/data/queries/useCourses.ts +42 -1
- package/src/data/queries/useEvents.ts +15 -1
- package/src/data/queries/usePaymentFlow.ts +12 -0
- package/src/data/queries/useWebsite.ts +6 -0
- package/src/index.ts +9 -0
- package/src/types/models.ts +66 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
- package/src/ui/headless/checkout/useCheckout.ts +156 -8
- package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
- package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
- package/src/ui/headless/coupon/useCouponField.ts +164 -0
- package/src/ui/headless/course/useCourseCheckout.ts +113 -18
- package/src/ui/headless/event/useEventCheckout.ts +53 -2
- package/src/ui/headless/index.ts +15 -0
- package/src/ui/index.ts +7 -0
- package/src/ui/shell/PoweredBy.tsx +60 -0
- package/src/ui/shell/TribeNestApp.tsx +15 -1
- package/src/ui/shell/shellGating.spec.ts +21 -1
- package/src/ui/shell/shellGating.ts +14 -0
- package/src/ui/styled/AddToCalendar.tsx +104 -0
- package/src/ui/styled/Checkout.tsx +45 -14
- package/src/ui/styled/CoachingBooking.tsx +28 -8
- package/src/ui/styled/CoachingConfirmation.tsx +12 -0
- package/src/ui/styled/CourseCheckout.tsx +49 -18
- package/src/ui/styled/DiscountCode.tsx +206 -0
- package/src/ui/styled/EventConfirmation.tsx +68 -22
- package/src/ui/styled/EventDetail.tsx +18 -5
- package/src/ui/styled/EventTickets.tsx +49 -5
- package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
- package/src/utils/ticketOrderOutcome.ts +125 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { bundleCouponSummary, bundleCouponRequestBody, bundleReturnUrl } from "../bundleCoupon";
|
|
3
|
+
import type { ApplyCheckoutCouponResult } from "../../../../data/queries/useCheckouts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A bundle's discount code, as `useCheckout` handles it.
|
|
7
|
+
*
|
|
8
|
+
* These are the two decisions that used to be impossible to make at all: before
|
|
9
|
+
* `/public/checkouts/apply-coupon` existed, a bundle's code was consumed by the
|
|
10
|
+
* call that CREATED the checkout, so for a signed-in buyer with a digital-only
|
|
11
|
+
* cart — who enters the payment stage on mount — the field froze before it could
|
|
12
|
+
* be typed into.
|
|
13
|
+
*
|
|
14
|
+
* They live outside the hook so they can be asserted without a provider tree,
|
|
15
|
+
* the same reason `couponFailureMessage` does.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The endpoint's answer, defaulted to "APPLYME20 took $2 off a $60 bundle". */
|
|
19
|
+
const answer = (overrides: Partial<ApplyCheckoutCouponResult> = {}): ApplyCheckoutCouponResult => ({
|
|
20
|
+
checkoutId: "checkout-1",
|
|
21
|
+
currency: "USD",
|
|
22
|
+
subtotalCents: 6000,
|
|
23
|
+
discountCents: 200,
|
|
24
|
+
couponId: "coupon-1",
|
|
25
|
+
totalCents: 5800,
|
|
26
|
+
appliedCoupons: [{ code: "APPLYME20", discountKind: "simple", discountAmount: 2 }],
|
|
27
|
+
paymentSecret: "pi_2_secret",
|
|
28
|
+
paymentId: "pi_2",
|
|
29
|
+
chargedAmount: 58,
|
|
30
|
+
chargedCurrency: "USD",
|
|
31
|
+
isFreeCheckout: false,
|
|
32
|
+
...overrides,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("bundleCouponSummary — applying", () => {
|
|
36
|
+
it("converts the minor-unit discount and names the coupon", () => {
|
|
37
|
+
const next = bundleCouponSummary(answer());
|
|
38
|
+
|
|
39
|
+
expect(next.discountAmount).toBe(2);
|
|
40
|
+
expect(next.appliedCoupon).toEqual({ code: "APPLYME20", discountAmount: 2 });
|
|
41
|
+
expect(next.settledCheckoutId).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("labels an AUTOMATIC discount the buyer never typed", () => {
|
|
45
|
+
// The whole point of the response carrying `appliedCoupons`: without it a
|
|
46
|
+
// no-code discount could only be drawn as an unexplained amount, because
|
|
47
|
+
// there is no public `couponId` → code lookup.
|
|
48
|
+
const next = bundleCouponSummary(
|
|
49
|
+
answer({ appliedCoupons: [{ code: "SUMMER10", discountKind: "simple", discountAmount: 6 }], discountCents: 600 }),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(next.appliedCoupon).toEqual({ code: "SUMMER10", discountAmount: 6 });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("moves the payment element onto the re-minted intent", () => {
|
|
56
|
+
// Applying re-prices AND re-mints. Leaving the old secret in place would
|
|
57
|
+
// have the buyer confirm a payment quoting the PRE-coupon amount.
|
|
58
|
+
const next = bundleCouponSummary(answer());
|
|
59
|
+
|
|
60
|
+
expect(next.paymentUpdate).toEqual({
|
|
61
|
+
paymentSecret: "pi_2_secret",
|
|
62
|
+
paymentId: "pi_2",
|
|
63
|
+
chargedAmount: 58,
|
|
64
|
+
chargedCurrency: "USD",
|
|
65
|
+
});
|
|
66
|
+
expect(next.chargedTotal).toEqual({ amount: 58, currency: "USD" });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("claims no payment when the bundle had no intent to replace", () => {
|
|
70
|
+
// A bundle that has not reached start-payment yet: the client's own
|
|
71
|
+
// start-payment will pick the new figure up, so nothing may supersede it.
|
|
72
|
+
const next = bundleCouponSummary(answer({ paymentSecret: "", paymentId: "", chargedAmount: 0, chargedCurrency: "" }));
|
|
73
|
+
|
|
74
|
+
expect(next.paymentUpdate).toBeNull();
|
|
75
|
+
expect(next.chargedTotal).toBeNull();
|
|
76
|
+
// The discount is still real and still shown.
|
|
77
|
+
expect(next.discountAmount).toBe(2);
|
|
78
|
+
expect(next.appliedCoupon).toEqual({ code: "APPLYME20", discountAmount: 2 });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("does not claim a coupon it cannot name", () => {
|
|
82
|
+
// A reduction with no coupon behind it is reported as an amount and nothing
|
|
83
|
+
// more — inventing a label is how a buyer ends up chasing a code that
|
|
84
|
+
// does not exist.
|
|
85
|
+
const next = bundleCouponSummary(answer({ appliedCoupons: [] }));
|
|
86
|
+
|
|
87
|
+
expect(next.discountAmount).toBe(2);
|
|
88
|
+
expect(next.appliedCoupon).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("bundleCouponSummary — removing", () => {
|
|
93
|
+
it("restores the undiscounted figure and drops the applied coupon", () => {
|
|
94
|
+
const next = bundleCouponSummary(
|
|
95
|
+
answer({
|
|
96
|
+
discountCents: 0,
|
|
97
|
+
couponId: null,
|
|
98
|
+
totalCents: 6000,
|
|
99
|
+
appliedCoupons: [],
|
|
100
|
+
paymentSecret: "pi_3_secret",
|
|
101
|
+
paymentId: "pi_3",
|
|
102
|
+
chargedAmount: 60,
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(next.discountAmount).toBe(0);
|
|
107
|
+
expect(next.appliedCoupon).toBeNull();
|
|
108
|
+
// The full price is what the buyer is now charged, on a fresh intent.
|
|
109
|
+
expect(next.chargedTotal).toEqual({ amount: 60, currency: "USD" });
|
|
110
|
+
expect(next.paymentUpdate?.paymentId).toBe("pi_3");
|
|
111
|
+
expect(next.settledCheckoutId).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("bundleCouponSummary — a bundle taken to zero", () => {
|
|
116
|
+
it("reports the settled checkout so the buyer is sent to finalise", () => {
|
|
117
|
+
// There is no intent to confirm: the server settled and fulfilled it. Left
|
|
118
|
+
// unhandled, the buyer waits in front of a payment form that will never be
|
|
119
|
+
// asked to do anything.
|
|
120
|
+
const next = bundleCouponSummary(
|
|
121
|
+
answer({
|
|
122
|
+
discountCents: 6000,
|
|
123
|
+
totalCents: 0,
|
|
124
|
+
appliedCoupons: [{ code: "ALLOFIT", discountKind: "simple", discountAmount: 60 }],
|
|
125
|
+
paymentSecret: "",
|
|
126
|
+
paymentId: "",
|
|
127
|
+
chargedAmount: 0,
|
|
128
|
+
chargedCurrency: "",
|
|
129
|
+
isFreeCheckout: true,
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(next.settledCheckoutId).toBe("checkout-1");
|
|
134
|
+
expect(next.paymentUpdate).toBeNull();
|
|
135
|
+
expect(next.chargedTotal).toBeNull();
|
|
136
|
+
expect(next.discountAmount).toBe(60);
|
|
137
|
+
expect(next.appliedCoupon).toEqual({ code: "ALLOFIT", discountAmount: 60 });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("bundleCouponRequestBody", () => {
|
|
142
|
+
const base = { profileId: "p1", checkoutId: "c1", returnUrl: "https://x.test/finalise?checkoutId=c1" };
|
|
143
|
+
|
|
144
|
+
it("sends the trimmed code when applying", () => {
|
|
145
|
+
expect(bundleCouponRequestBody({ ...base, couponCode: " applyme20 " })).toEqual({
|
|
146
|
+
...base,
|
|
147
|
+
couponCode: "applyme20",
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("omits the field entirely when removing", () => {
|
|
152
|
+
// The endpoint validates `couponCode` as `min(1)`, so an empty string is a
|
|
153
|
+
// 400 — "take my code off" would fail and the discount would stay on.
|
|
154
|
+
expect(bundleCouponRequestBody(base)).toEqual(base);
|
|
155
|
+
expect(bundleCouponRequestBody({ ...base, couponCode: "" })).toEqual(base);
|
|
156
|
+
expect(bundleCouponRequestBody({ ...base, couponCode: " " })).toEqual(base);
|
|
157
|
+
expect("couponCode" in bundleCouponRequestBody(base)).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("bundleReturnUrl", () => {
|
|
162
|
+
it("returns the buyer to the bundle's own finalise page", () => {
|
|
163
|
+
// `?checkoutId=` and not `?orderId=`: the finalise page resolves a bundle
|
|
164
|
+
// through the checkout, and a bundle's children are several orders.
|
|
165
|
+
expect(bundleReturnUrl("https://artist.test", "/checkout/finalise", "c1")).toBe(
|
|
166
|
+
"https://artist.test/checkout/finalise?checkoutId=c1",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ApplyCheckoutCouponResult, AppliedBundleCoupon } from "../../../data/queries/useCheckouts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two decisions a bundle's discount code turns on, kept out of the hook so
|
|
5
|
+
* they can be asserted without a provider tree.
|
|
6
|
+
*
|
|
7
|
+
* Both exist because a bundle is the odd one out: it is the only checkout that
|
|
8
|
+
* answers in MINOR units, the only one whose "remove" is expressed by the
|
|
9
|
+
* ABSENCE of a field rather than an empty one, and the only one that can settle
|
|
10
|
+
* itself — a code that takes it to zero leaves nothing to pay for.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** What the summary and the payment element read after an apply or a remove. */
|
|
14
|
+
export type BundleCouponSummary = {
|
|
15
|
+
/** MAJOR units, converted from the endpoint's minor-unit answer. */
|
|
16
|
+
discountAmount: number;
|
|
17
|
+
/**
|
|
18
|
+
* The discount as the buyer sees it, or null when nothing came off.
|
|
19
|
+
*
|
|
20
|
+
* Named from the server's `appliedCoupons`, so an AUTOMATIC discount the buyer
|
|
21
|
+
* never typed is labelled with its real code instead of appearing as an
|
|
22
|
+
* unexplained reduction.
|
|
23
|
+
*/
|
|
24
|
+
appliedCoupon: { code: string; discountAmount: number } | null;
|
|
25
|
+
/**
|
|
26
|
+
* The re-minted intent. Applying re-prices AND re-mints, so the payment
|
|
27
|
+
* element must move onto this secret — the previous one still quotes the
|
|
28
|
+
* pre-coupon amount. Null when there was no intent to replace (the bundle had
|
|
29
|
+
* not started payment) or when the bundle settled for free.
|
|
30
|
+
*/
|
|
31
|
+
paymentUpdate: {
|
|
32
|
+
paymentSecret: string;
|
|
33
|
+
paymentId: string;
|
|
34
|
+
chargedAmount: number;
|
|
35
|
+
chargedCurrency: string;
|
|
36
|
+
} | null;
|
|
37
|
+
/** The charged total for the summary, or null when there is nothing to charge. */
|
|
38
|
+
chargedTotal: { amount: number; currency: string } | null;
|
|
39
|
+
/**
|
|
40
|
+
* Set when the re-price took the bundle to zero. The server has already
|
|
41
|
+
* settled and fulfilled it, so the buyer belongs on the finalise page rather
|
|
42
|
+
* than in front of a payment form that will never be asked to do anything.
|
|
43
|
+
*/
|
|
44
|
+
settledCheckoutId: string | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function bundleCouponSummary(data: ApplyCheckoutCouponResult): BundleCouponSummary {
|
|
48
|
+
const discountAmount = (data.discountCents ?? 0) / 100;
|
|
49
|
+
const named: AppliedBundleCoupon | undefined = data.appliedCoupons?.[0];
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
discountAmount,
|
|
53
|
+
// A discount with no coupon behind it cannot be labelled honestly, so it is
|
|
54
|
+
// not claimed as one — `discountAmount` still reports the reduction.
|
|
55
|
+
appliedCoupon: discountAmount > 0 && named ? { code: named.code, discountAmount } : null,
|
|
56
|
+
paymentUpdate:
|
|
57
|
+
data.paymentSecret && data.paymentId
|
|
58
|
+
? {
|
|
59
|
+
paymentSecret: data.paymentSecret,
|
|
60
|
+
paymentId: data.paymentId,
|
|
61
|
+
chargedAmount: data.chargedAmount,
|
|
62
|
+
chargedCurrency: data.chargedCurrency,
|
|
63
|
+
}
|
|
64
|
+
: null,
|
|
65
|
+
chargedTotal: data.chargedAmount > 0 ? { amount: data.chargedAmount, currency: data.chargedCurrency } : null,
|
|
66
|
+
settledCheckoutId: data.isFreeCheckout ? data.checkoutId : null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The body for `POST /public/checkouts/apply-coupon`.
|
|
72
|
+
*
|
|
73
|
+
* Removing sends NO `couponCode` at all. An empty string is a validation error
|
|
74
|
+
* on that endpoint (`min(1)`), so sending one would turn "take my code off" into
|
|
75
|
+
* a 400 and leave the discount in place.
|
|
76
|
+
*/
|
|
77
|
+
export function bundleCouponRequestBody(input: {
|
|
78
|
+
/** Optional only because `useForge()` types it so before the provider resolves. */
|
|
79
|
+
profileId?: string;
|
|
80
|
+
checkoutId: string;
|
|
81
|
+
returnUrl: string;
|
|
82
|
+
couponCode?: string;
|
|
83
|
+
}): Record<string, unknown> {
|
|
84
|
+
const code = input.couponCode?.trim();
|
|
85
|
+
return {
|
|
86
|
+
profileId: input.profileId,
|
|
87
|
+
checkoutId: input.checkoutId,
|
|
88
|
+
returnUrl: input.returnUrl,
|
|
89
|
+
...(code ? { couponCode: code } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The return URL a bundle's re-minted intent comes back to. */
|
|
94
|
+
export function bundleReturnUrl(origin: string, finalisePath: string, checkoutId: string): string {
|
|
95
|
+
return `${origin}${finalisePath}?checkoutId=${checkoutId}`;
|
|
96
|
+
}
|
|
@@ -11,7 +11,13 @@ import {
|
|
|
11
11
|
type ShippingCountry,
|
|
12
12
|
} from "../../../data/queries/useShipping";
|
|
13
13
|
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
useCreateCheckout,
|
|
16
|
+
useApplyCheckoutCoupon,
|
|
17
|
+
cartToCheckoutLines,
|
|
18
|
+
type ApplyCheckoutCouponResult,
|
|
19
|
+
} from "../../../data/queries/useCheckouts";
|
|
20
|
+
import { bundleCouponSummary, bundleReturnUrl } from "./bundleCoupon";
|
|
15
21
|
import { readAttributionRef } from "../../../utils/attribution";
|
|
16
22
|
import { readLanding } from "../../../utils/landing";
|
|
17
23
|
import { ProductDeliveryType, PaymentProviderName, type ApiError, type PublicTaxQuote } from "../../../types/models";
|
|
@@ -52,6 +58,12 @@ interface AppliedCoupon {
|
|
|
52
58
|
code: string;
|
|
53
59
|
discountType: string | null;
|
|
54
60
|
discountValue: number | null;
|
|
61
|
+
/**
|
|
62
|
+
* MAJOR units. Set for the BUNDLE path only, where the endpoint reports what
|
|
63
|
+
* came off (`discountCents`) but not the coupon's type or rate — so "10% off"
|
|
64
|
+
* cannot be rendered and the cash figure is the honest thing to show.
|
|
65
|
+
*/
|
|
66
|
+
discountAmount?: number;
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
interface CouponPaymentUpdate {
|
|
@@ -96,6 +108,7 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
96
108
|
const createOrder = useCreateOrder();
|
|
97
109
|
const createCheckout = useCreateCheckout();
|
|
98
110
|
const applyCouponMutation = useApplyCoupon();
|
|
111
|
+
const applyCheckoutCouponMutation = useApplyCheckoutCoupon();
|
|
99
112
|
|
|
100
113
|
const hasPhysicalProduct = useMemo(
|
|
101
114
|
() => cartItems.some((item) => item.deliveryType === ProductDeliveryType.Physical),
|
|
@@ -153,6 +166,12 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
153
166
|
const [created, setCreated] = useState<CreateOrderResult | null>(null);
|
|
154
167
|
/** Set instead of `created` when the cart is a bundle. */
|
|
155
168
|
const [checkoutId, setCheckoutId] = useState<string | null>(null);
|
|
169
|
+
/**
|
|
170
|
+
* A bundle a discount took to zero. Applying such a code settles it on the
|
|
171
|
+
* server (there is no intent to mint), so there is nothing left to pay and the
|
|
172
|
+
* caller has to move the buyer straight to the finalise page.
|
|
173
|
+
*/
|
|
174
|
+
const [settledCheckoutId, setSettledCheckoutId] = useState<string | null>(null);
|
|
156
175
|
const [startError, setStartError] = useState("");
|
|
157
176
|
const startedRef = useRef(false);
|
|
158
177
|
|
|
@@ -184,6 +203,7 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
184
203
|
// both settle on one payment. Every single-surface cart keeps the flow it
|
|
185
204
|
// has always had, below.
|
|
186
205
|
if (isBundle) {
|
|
206
|
+
const enteredCode = couponCode.trim() || undefined;
|
|
187
207
|
createCheckout
|
|
188
208
|
.mutateAsync({
|
|
189
209
|
firstName: guestUserData?.firstName || user?.firstName || "",
|
|
@@ -194,10 +214,41 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
194
214
|
selectedShippingRates: selectedShippingRates.length ? selectedShippingRates : undefined,
|
|
195
215
|
attributionRefId: readAttributionRef() ?? undefined,
|
|
196
216
|
...(readLanding() ?? {}),
|
|
217
|
+
// A code typed BEFORE this call still travels with it — that is the
|
|
218
|
+
// cheapest path and the only one available to a guest, whose bundle
|
|
219
|
+
// does not exist yet. A code typed AFTERWARDS goes through
|
|
220
|
+
// `/public/checkouts/apply-coupon`, which is what stops the field
|
|
221
|
+
// freezing for a signed-in buyer whose digital-only cart enters the
|
|
222
|
+
// payment stage on mount.
|
|
223
|
+
couponCode: enteredCode,
|
|
197
224
|
lines: cartToCheckoutLines(cartItems, ticketItems),
|
|
198
225
|
})
|
|
199
|
-
.then((r) =>
|
|
200
|
-
|
|
226
|
+
.then((r) => {
|
|
227
|
+
setCheckoutId(r.checkoutId);
|
|
228
|
+
// Minor → major. The bundle is the only endpoint answering in minor units.
|
|
229
|
+
const off = (r.discountCents ?? 0) / 100;
|
|
230
|
+
setDiscountAmount(off);
|
|
231
|
+
// `appliedCoupons` names what came off, so an AUTOMATIC discount the
|
|
232
|
+
// buyer never typed is labelled rather than being an unexplained
|
|
233
|
+
// amount. The typed code is the fallback for an older API.
|
|
234
|
+
const named = r.appliedCoupons?.[0];
|
|
235
|
+
setAppliedCoupon(
|
|
236
|
+
off > 0
|
|
237
|
+
? {
|
|
238
|
+
code: named?.code ?? enteredCode ?? "Discount applied",
|
|
239
|
+
discountType: null,
|
|
240
|
+
discountValue: null,
|
|
241
|
+
discountAmount: off,
|
|
242
|
+
}
|
|
243
|
+
: null,
|
|
244
|
+
);
|
|
245
|
+
})
|
|
246
|
+
.catch((e) => {
|
|
247
|
+
// A refused code fails the whole bundle, so with one entered the
|
|
248
|
+
// reason belongs at the field the buyer used — verbatim from the API.
|
|
249
|
+
if (enteredCode) setCouponError(messageOf(e) || "This code could not be applied.");
|
|
250
|
+
else setStartError(messageOf(e) || "An error occurred");
|
|
251
|
+
});
|
|
201
252
|
return;
|
|
202
253
|
}
|
|
203
254
|
|
|
@@ -243,7 +294,10 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
243
294
|
// and start-payment have resolved.
|
|
244
295
|
const effectivePayment = useMemo<CouponPaymentUpdate | null>(() => {
|
|
245
296
|
if (couponPaymentUpdate) return couponPaymentUpdate;
|
|
246
|
-
|
|
297
|
+
// `checkoutId` as well as `created`: a BUNDLE has no `created` order, and
|
|
298
|
+
// gating on that alone left a bundle buyer with no payment element at all
|
|
299
|
+
// unless a coupon happened to re-mint one.
|
|
300
|
+
if ((created || checkoutId) && flow.result?.paymentSecret && flow.result?.paymentId) {
|
|
247
301
|
return {
|
|
248
302
|
paymentSecret: flow.result.paymentSecret,
|
|
249
303
|
paymentId: flow.result.paymentId,
|
|
@@ -252,7 +306,7 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
252
306
|
};
|
|
253
307
|
}
|
|
254
308
|
return null;
|
|
255
|
-
}, [created, flow.result, couponPaymentUpdate]);
|
|
309
|
+
}, [created, checkoutId, flow.result, couponPaymentUpdate]);
|
|
256
310
|
|
|
257
311
|
// Surface the charged total (for the summary) as soon as a payment resolves.
|
|
258
312
|
useEffect(() => {
|
|
@@ -317,8 +371,66 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
317
371
|
setCurrentStage("payment");
|
|
318
372
|
}, [shippingGroups, selections]);
|
|
319
373
|
|
|
320
|
-
// ── Coupon (applied against the created order)
|
|
374
|
+
// ── Coupon (applied against the created order / bundle) ─────────────────────
|
|
375
|
+
/**
|
|
376
|
+
* Whether the code field can still be changed.
|
|
377
|
+
*
|
|
378
|
+
* Both shapes are now re-priceable in place: a single-surface order through
|
|
379
|
+
* `/public/orders/apply-coupon`, a bundle through
|
|
380
|
+
* `/public/checkouts/apply-coupon`. A bundle's field used to freeze the moment
|
|
381
|
+
* the checkout was created — which for a signed-in buyer with a digital-only
|
|
382
|
+
* cart was on mount, so the code could never be typed at all.
|
|
383
|
+
*/
|
|
384
|
+
const isCouponEditable = isBundle ? !settledCheckoutId : !!orderId;
|
|
385
|
+
|
|
386
|
+
/** Fold an apply/remove answer for a bundle back into the summary + payment. */
|
|
387
|
+
const absorbBundleCoupon = useCallback((data: ApplyCheckoutCouponResult) => {
|
|
388
|
+
const next = bundleCouponSummary(data);
|
|
389
|
+
setDiscountAmount(next.discountAmount);
|
|
390
|
+
setAppliedCoupon(
|
|
391
|
+
next.appliedCoupon
|
|
392
|
+
? {
|
|
393
|
+
code: next.appliedCoupon.code,
|
|
394
|
+
discountType: null,
|
|
395
|
+
discountValue: null,
|
|
396
|
+
discountAmount: next.appliedCoupon.discountAmount,
|
|
397
|
+
}
|
|
398
|
+
: null,
|
|
399
|
+
);
|
|
400
|
+
setCouponPaymentUpdate(next.paymentUpdate);
|
|
401
|
+
if (next.chargedTotal) setChargedTotal(next.chargedTotal);
|
|
402
|
+
if (next.settledCheckoutId) setSettledCheckoutId(next.settledCheckoutId);
|
|
403
|
+
}, []);
|
|
404
|
+
|
|
321
405
|
const applyCoupon = useCallback(async () => {
|
|
406
|
+
if (isBundle) {
|
|
407
|
+
if (!couponCode.trim()) return;
|
|
408
|
+
setCouponError("");
|
|
409
|
+
// Not created yet (a guest, or a cart with a shipping stage still ahead):
|
|
410
|
+
// the code travels with `createCheckout`, which is the one call that can
|
|
411
|
+
// price it without leaving an abandoned bundle behind.
|
|
412
|
+
if (!checkoutId) {
|
|
413
|
+
setAppliedCoupon({ code: couponCode.trim(), discountType: null, discountValue: null });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
setIsApplyingCoupon(true);
|
|
417
|
+
try {
|
|
418
|
+
const data = await applyCheckoutCouponMutation.mutateAsync({
|
|
419
|
+
checkoutId,
|
|
420
|
+
returnUrl: bundleReturnUrl(window.location.origin, finalisePath, checkoutId),
|
|
421
|
+
couponCode: couponCode.trim(),
|
|
422
|
+
});
|
|
423
|
+
absorbBundleCoupon(data);
|
|
424
|
+
} catch (e) {
|
|
425
|
+
// The API answers a refused code with the real reason, already
|
|
426
|
+
// translated. Collapsing it into "invalid code" throws away the only
|
|
427
|
+
// thing that tells the buyer what to do next.
|
|
428
|
+
setCouponError(messageOf(e) || "Invalid coupon code");
|
|
429
|
+
} finally {
|
|
430
|
+
setIsApplyingCoupon(false);
|
|
431
|
+
}
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
322
434
|
if (!couponCode.trim() || !orderId) return;
|
|
323
435
|
setCouponError("");
|
|
324
436
|
setIsApplyingCoupon(true);
|
|
@@ -344,9 +456,37 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
344
456
|
} finally {
|
|
345
457
|
setIsApplyingCoupon(false);
|
|
346
458
|
}
|
|
347
|
-
}, [couponCode, orderId, finalisePath, applyCouponMutation]);
|
|
459
|
+
}, [couponCode, orderId, checkoutId, finalisePath, applyCouponMutation, applyCheckoutCouponMutation, absorbBundleCoupon, isBundle]);
|
|
348
460
|
|
|
349
461
|
const removeCoupon = useCallback(async () => {
|
|
462
|
+
if (isBundle) {
|
|
463
|
+
setCouponError("");
|
|
464
|
+
// Nothing created yet — the staged code simply never travels.
|
|
465
|
+
if (!checkoutId) {
|
|
466
|
+
setAppliedCoupon(null);
|
|
467
|
+
setCouponCode("");
|
|
468
|
+
setShowCouponInput(false);
|
|
469
|
+
setDiscountAmount(0);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
setIsApplyingCoupon(true);
|
|
473
|
+
try {
|
|
474
|
+
// No code sent: the bundle is re-priced with none, which restores the
|
|
475
|
+
// original total rather than freezing the last discounted one.
|
|
476
|
+
const data = await applyCheckoutCouponMutation.mutateAsync({
|
|
477
|
+
checkoutId,
|
|
478
|
+
returnUrl: bundleReturnUrl(window.location.origin, finalisePath, checkoutId),
|
|
479
|
+
});
|
|
480
|
+
absorbBundleCoupon(data);
|
|
481
|
+
setCouponCode("");
|
|
482
|
+
setShowCouponInput(false);
|
|
483
|
+
} catch (e) {
|
|
484
|
+
setCouponError(messageOf(e) || "Failed to remove coupon");
|
|
485
|
+
} finally {
|
|
486
|
+
setIsApplyingCoupon(false);
|
|
487
|
+
}
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
350
490
|
if (!orderId) return;
|
|
351
491
|
setCouponError("");
|
|
352
492
|
setIsApplyingCoupon(true);
|
|
@@ -372,7 +512,7 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
372
512
|
} finally {
|
|
373
513
|
setIsApplyingCoupon(false);
|
|
374
514
|
}
|
|
375
|
-
}, [orderId, finalisePath, applyCouponMutation]);
|
|
515
|
+
}, [orderId, checkoutId, finalisePath, applyCouponMutation, applyCheckoutCouponMutation, absorbBundleCoupon, isBundle]);
|
|
376
516
|
|
|
377
517
|
// ── Free checkout (nothing to charge) ───────────────────────────────────────
|
|
378
518
|
/** Create the (free) order and return its id so the caller can navigate to the
|
|
@@ -439,6 +579,12 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
439
579
|
orderId,
|
|
440
580
|
/** Set instead of `orderId` when the cart spans surfaces. */
|
|
441
581
|
checkoutId,
|
|
582
|
+
/**
|
|
583
|
+
* Set when a discount took the bundle to zero: it is already paid and
|
|
584
|
+
* fulfilled server-side, so the caller must send the buyer to the finalise
|
|
585
|
+
* page instead of waiting for a payment that will never be asked for.
|
|
586
|
+
*/
|
|
587
|
+
settledCheckoutId,
|
|
442
588
|
isBundle,
|
|
443
589
|
ticketItems,
|
|
444
590
|
isCreatingOrder: createOrder.isPending || flow.isStarting,
|
|
@@ -463,6 +609,8 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
463
609
|
isApplyingCoupon,
|
|
464
610
|
applyCoupon,
|
|
465
611
|
removeCoupon,
|
|
612
|
+
/** False once the code can no longer be changed (a created bundle). */
|
|
613
|
+
isCouponEditable,
|
|
466
614
|
// ── Errors ─────────────────────────────────────────────────────────────────
|
|
467
615
|
pageError,
|
|
468
616
|
setPageError,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
useUnreserveCoachingBooking,
|
|
9
9
|
} from "../../../data/queries/useCoachingAvailability";
|
|
10
10
|
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
11
|
+
import { useCouponField, type CouponQuoteFn } from "../coupon/useCouponField";
|
|
11
12
|
import { readAttributionRef } from "../../../utils/attribution";
|
|
12
13
|
import { readLanding } from "../../../utils/landing";
|
|
13
14
|
|
|
@@ -93,6 +94,37 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
|
|
|
93
94
|
const finalisePath =
|
|
94
95
|
opts.finalisePath ?? ((s, bId) => `/i/coaching/${s}/finalise?orderId=${bId}`);
|
|
95
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Discount code — genuinely quoted, because `booking/update` re-derives the
|
|
99
|
+
* gross price from the PRODUCT on every call. Applying re-prices the held
|
|
100
|
+
* booking and removing (no code) puts the original total back rather than
|
|
101
|
+
* netting a second time off an already-netted figure.
|
|
102
|
+
*
|
|
103
|
+
* `confirmIfFree` is deliberately false here: pricing a code must never be
|
|
104
|
+
* able to complete the booking. Only "Continue" confirms.
|
|
105
|
+
*/
|
|
106
|
+
const quoteCoupon: CouponQuoteFn = useCallback(
|
|
107
|
+
async (code) => {
|
|
108
|
+
if (!bookingId) throw new Error("Reserve a time slot before entering a code.");
|
|
109
|
+
if (!firstName.trim() || !lastName.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
110
|
+
throw new Error("Enter your name and a valid email before applying a code.");
|
|
111
|
+
}
|
|
112
|
+
return update.mutateAsync({
|
|
113
|
+
bookingId,
|
|
114
|
+
email,
|
|
115
|
+
firstName,
|
|
116
|
+
lastName,
|
|
117
|
+
confirmIfFree: false,
|
|
118
|
+
questionnaire,
|
|
119
|
+
couponCode: code ?? undefined,
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
// `update` is a stable react-query mutation object.
|
|
123
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
124
|
+
[bookingId, firstName, lastName, email, questionnaire],
|
|
125
|
+
);
|
|
126
|
+
const coupon = useCouponField(quoteCoupon);
|
|
127
|
+
|
|
96
128
|
// Highlight a slot (no server hold yet — the slot is reserved on "Continue").
|
|
97
129
|
const selectSlot = (slotId: string) => {
|
|
98
130
|
setError(null);
|
|
@@ -139,8 +171,12 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
|
|
|
139
171
|
setBookingId(null);
|
|
140
172
|
setSelectedSlotId(null);
|
|
141
173
|
setReservationExpiresAt(null);
|
|
174
|
+
// The quote belonged to a booking that no longer exists — keeping it would
|
|
175
|
+
// show a discount against a price nothing has agreed to.
|
|
176
|
+
coupon.reset();
|
|
142
177
|
setStep("slot");
|
|
143
|
-
|
|
178
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
179
|
+
}, [bookingId, unreserve, coupon.reset]);
|
|
144
180
|
|
|
145
181
|
/** Release the hold and surface a notice — call when the reservation timer runs out. */
|
|
146
182
|
const expireReservation = useCallback(async () => {
|
|
@@ -163,9 +199,12 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
|
|
|
163
199
|
lastName,
|
|
164
200
|
confirmIfFree: true,
|
|
165
201
|
questionnaire,
|
|
202
|
+
// Re-sent so the booking is confirmed at the price the buyer was shown.
|
|
203
|
+
couponCode: coupon.submittedCode,
|
|
166
204
|
attributionRefId: readAttributionRef() ?? undefined,
|
|
167
205
|
...(readLanding() ?? {}),
|
|
168
206
|
});
|
|
207
|
+
coupon.record(updated);
|
|
169
208
|
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
170
209
|
const ru = `${origin}${finalisePath(slug, bookingId)}`;
|
|
171
210
|
setReturnUrl(ru);
|
|
@@ -180,7 +219,10 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
|
|
|
180
219
|
if (result.provider && result.provider !== "stripe") return;
|
|
181
220
|
setStep("payment");
|
|
182
221
|
} catch (e) {
|
|
183
|
-
|
|
222
|
+
// With a code entered, the API's rejection reason belongs at the field the
|
|
223
|
+
// buyer used, not in the generic error slot.
|
|
224
|
+
if (coupon.submittedCode) coupon.fail(e);
|
|
225
|
+
else setError(errMessage(e));
|
|
184
226
|
}
|
|
185
227
|
};
|
|
186
228
|
|
|
@@ -213,7 +255,15 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
|
|
|
213
255
|
setQuestionnaire,
|
|
214
256
|
continueToPayment,
|
|
215
257
|
clientSecret: flow.clientSecret,
|
|
216
|
-
|
|
258
|
+
// The booking quote WINS over start-payment: every `continueToPayment`
|
|
259
|
+
// re-quotes before starting the payment, and applying or removing a code
|
|
260
|
+
// re-quotes again — so the quote is never staler, and preferring the
|
|
261
|
+
// payment result would leave a removed discount on screen.
|
|
262
|
+
totalAmount: coupon.quote?.totalAmount ?? flow.result?.totalAmount,
|
|
263
|
+
/** GROSS session price, before any discount. Undefined until quoted. */
|
|
264
|
+
subTotal: coupon.quote?.subTotal,
|
|
265
|
+
/** Discount code state + the server's quote. See `useCouponField`. */
|
|
266
|
+
coupon,
|
|
217
267
|
/** Authoritative sales-tax quote from start-payment (display only). */
|
|
218
268
|
taxQuote: flow.result?.taxQuote ?? null,
|
|
219
269
|
returnUrl,
|