@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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/useCheckouts.ts +84 -1
  3. package/src/data/queries/useCoachingAvailability.ts +18 -3
  4. package/src/data/queries/useCourses.ts +42 -1
  5. package/src/data/queries/useEvents.ts +15 -1
  6. package/src/data/queries/usePaymentFlow.ts +12 -0
  7. package/src/data/queries/useWebsite.ts +6 -0
  8. package/src/index.ts +9 -0
  9. package/src/types/models.ts +66 -0
  10. package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
  11. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
  12. package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
  13. package/src/ui/headless/checkout/useCheckout.ts +156 -8
  14. package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
  15. package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
  16. package/src/ui/headless/coupon/useCouponField.ts +164 -0
  17. package/src/ui/headless/course/useCourseCheckout.ts +113 -18
  18. package/src/ui/headless/event/useEventCheckout.ts +53 -2
  19. package/src/ui/headless/index.ts +15 -0
  20. package/src/ui/index.ts +7 -0
  21. package/src/ui/shell/PoweredBy.tsx +60 -0
  22. package/src/ui/shell/TribeNestApp.tsx +15 -1
  23. package/src/ui/shell/shellGating.spec.ts +21 -1
  24. package/src/ui/shell/shellGating.ts +14 -0
  25. package/src/ui/styled/AddToCalendar.tsx +104 -0
  26. package/src/ui/styled/Checkout.tsx +45 -14
  27. package/src/ui/styled/CoachingBooking.tsx +28 -8
  28. package/src/ui/styled/CoachingConfirmation.tsx +12 -0
  29. package/src/ui/styled/CourseCheckout.tsx +49 -18
  30. package/src/ui/styled/DiscountCode.tsx +206 -0
  31. package/src/ui/styled/EventConfirmation.tsx +68 -22
  32. package/src/ui/styled/EventDetail.tsx +18 -5
  33. package/src/ui/styled/EventTickets.tsx +49 -5
  34. package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
  35. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
  36. package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
  37. 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 { useCreateCheckout, cartToCheckoutLines } from "../../../data/queries/useCheckouts";
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) => setCheckoutId(r.checkoutId))
200
- .catch((e) => setStartError(messageOf(e) || "An error occurred"));
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
- if (created && flow.result?.paymentSecret && flow.result?.paymentId) {
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
- }, [bookingId, unreserve]);
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
- setError(errMessage(e));
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
- totalAmount: flow.result?.totalAmount,
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,