@tribe-nest/forge 2.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 (54) hide show
  1. package/package.json +1 -1
  2. package/src/client/createForgeClient.ts +85 -2
  3. package/src/client/tokenStorage.ts +31 -0
  4. package/src/contexts/AppAuthContext.tsx +100 -15
  5. package/src/contexts/PublicAuthContext.tsx +46 -9
  6. package/src/data/queries/useCheckouts.ts +84 -1
  7. package/src/data/queries/useCoachingAvailability.ts +18 -3
  8. package/src/data/queries/useCourseAccess.ts +1 -1
  9. package/src/data/queries/useCourses.ts +42 -1
  10. package/src/data/queries/useEvents.ts +15 -1
  11. package/src/data/queries/usePaymentFlow.ts +12 -0
  12. package/src/data/queries/useWebsite.ts +6 -0
  13. package/src/index.ts +19 -2
  14. package/src/provider/ForgeProvider.tsx +30 -1
  15. package/src/server/_tests/platformEvents.spec.ts +315 -0
  16. package/src/server/index.ts +17 -0
  17. package/src/server/jobs.ts +41 -10
  18. package/src/server/platform.ts +234 -9
  19. package/src/server/platformEvents.generated.ts +422 -0
  20. package/src/types/models.ts +110 -3
  21. package/src/ui/headless/auth/useSignupForm.ts +69 -4
  22. package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
  23. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
  24. package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
  25. package/src/ui/headless/checkout/useCheckout.ts +156 -8
  26. package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
  27. package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
  28. package/src/ui/headless/coupon/useCouponField.ts +164 -0
  29. package/src/ui/headless/course/useCourseCheckout.ts +113 -18
  30. package/src/ui/headless/event/useEventCheckout.ts +53 -2
  31. package/src/ui/headless/index.ts +15 -0
  32. package/src/ui/headless/work/useWorkPortal.ts +24 -21
  33. package/src/ui/index.ts +7 -0
  34. package/src/ui/shell/PoweredBy.tsx +60 -0
  35. package/src/ui/shell/TribeNestApp.tsx +15 -1
  36. package/src/ui/shell/shellGating.spec.ts +21 -1
  37. package/src/ui/shell/shellGating.ts +14 -0
  38. package/src/ui/styled/AddToCalendar.tsx +104 -0
  39. package/src/ui/styled/Checkout.tsx +45 -14
  40. package/src/ui/styled/CoachingBooking.tsx +28 -8
  41. package/src/ui/styled/CoachingConfirmation.tsx +12 -0
  42. package/src/ui/styled/CourseCheckout.tsx +49 -18
  43. package/src/ui/styled/DiscountCode.tsx +206 -0
  44. package/src/ui/styled/EventConfirmation.tsx +68 -22
  45. package/src/ui/styled/EventDetail.tsx +18 -5
  46. package/src/ui/styled/EventTickets.tsx +49 -5
  47. package/src/ui/styled/SignupForm.tsx +86 -35
  48. package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
  49. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
  50. package/src/ui/styled/work/WorkInviteAccept.tsx +54 -5
  51. package/src/utils/_tests/safeRedirect.spec.ts +117 -0
  52. package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
  53. package/src/utils/safeRedirect.ts +41 -0
  54. package/src/utils/ticketOrderOutcome.ts +125 -0
@@ -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,
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { apiErrorMessage, couponFailureMessage } from "../useCouponField";
3
+
4
+ /**
5
+ * The one decision that determines whether a buyer learns anything from a
6
+ * refused code. The backend answers with the reason ALREADY translated —
7
+ * "This coupon has expired", "Your order does not reach this coupon's minimum
8
+ * spend" — so the only correct behaviour is to pass that string through.
9
+ */
10
+
11
+ /** An axios-shaped rejection, which is what every checkout actually catches. */
12
+ const axiosError = (message: string) => {
13
+ const e = new Error("Request failed with status code 400") as Error & {
14
+ response: { status: number; data: { status: number; message: string } };
15
+ };
16
+ e.response = { status: 400, data: { status: 400, message } };
17
+ return e;
18
+ };
19
+
20
+ describe("apiErrorMessage", () => {
21
+ it("reads the server's message off the response body", () => {
22
+ expect(apiErrorMessage(axiosError("This coupon has expired"))).toBe("This coupon has expired");
23
+ });
24
+
25
+ it("returns nothing for a failure that carried no server message", () => {
26
+ expect(apiErrorMessage(new Error("boom"))).toBeUndefined();
27
+ expect(apiErrorMessage(undefined)).toBeUndefined();
28
+ expect(apiErrorMessage(null)).toBeUndefined();
29
+ expect(apiErrorMessage({ response: {} })).toBeUndefined();
30
+ });
31
+ });
32
+
33
+ describe("couponFailureMessage", () => {
34
+ it("shows the API's reason verbatim, never the house fallback", () => {
35
+ // Every distinct reason the three pillars can produce for an ENTERED code.
36
+ for (const reason of [
37
+ "This coupon is no longer active",
38
+ "This coupon is not available yet",
39
+ "This coupon has expired",
40
+ "This coupon is not valid for your email",
41
+ "This coupon can only be used with an email address",
42
+ "Your order does not reach this coupon's minimum spend",
43
+ "Your order does not reach this coupon's minimum quantity",
44
+ "This coupon is only valid on a first order",
45
+ "This coupon is only available to members of a specific tier",
46
+ "This coupon is not available to you",
47
+ "This coupon has reached its maximum redemptions",
48
+ "You have already used this coupon the maximum number of times",
49
+ "This coupon does not apply to anything in this order",
50
+ "This coupon is not set up correctly and cannot be used",
51
+ "This coupon cannot be combined with another discount on this order",
52
+ "Invalid coupon code",
53
+ "This coupon cannot be used here",
54
+ ]) {
55
+ expect(couponFailureMessage(axiosError(reason), "This code could not be applied.")).toBe(reason);
56
+ }
57
+ });
58
+
59
+ it("never surfaces axios's own transport wording", () => {
60
+ // `Error.message` on a 400 is "Request failed with status code 400" — a
61
+ // string that tells the buyer nothing and looks like a bug.
62
+ const shown = couponFailureMessage(axiosError("This coupon has expired"), "fallback");
63
+ expect(shown).not.toContain("status code");
64
+ expect(shown).not.toBe("fallback");
65
+ });
66
+
67
+ it("suppresses the transport wording even when the body carried no message", () => {
68
+ const e = new Error("Request failed with status code 500") as Error & { response: { data: unknown } };
69
+ e.response = { data: {} };
70
+ expect(couponFailureMessage(e, "This code could not be applied.")).toBe("This code could not be applied.");
71
+ });
72
+
73
+ it("keeps a locally thrown precondition, which is ours to word", () => {
74
+ // "Reserve a slot first" is not a coupon rejection and has no server key.
75
+ expect(couponFailureMessage(new Error("Reserve a time slot before entering a code."), "fallback")).toBe(
76
+ "Reserve a time slot before entering a code.",
77
+ );
78
+ });
79
+
80
+ it("falls back only when there is genuinely nothing to say", () => {
81
+ expect(couponFailureMessage({}, "This code could not be applied.")).toBe("This code could not be applied.");
82
+ expect(couponFailureMessage(undefined, "This code could not be applied.")).toBe("This code could not be applied.");
83
+ });
84
+ });
@@ -0,0 +1,164 @@
1
+ import { useCallback, useState } from "react";
2
+ import type { AppliedDiscountCoupon, PillarDiscountQuote } from "../../../types/models";
3
+
4
+ /**
5
+ * Re-quote a pillar with (or, with `null`, without) a discount code and return
6
+ * what the SERVER says the buyer now owes.
7
+ *
8
+ * Only the pillars whose pre-payment step is re-runnable can supply one:
9
+ * coaching and courses both hang a `booking/update` endpoint off an already
10
+ * reserved booking, and that endpoint re-derives the gross price from the
11
+ * product on every call precisely so it can be fired repeatedly.
12
+ *
13
+ * Event tickets and bundles have no such endpoint — `POST /public/events/:id/orders`
14
+ * and `POST /public/checkouts` CREATE the record, so calling either one twice
15
+ * to preview a code would leave a real abandoned order behind. Those two pass
16
+ * no quote function and instead stage the code, hand it to the one call they do
17
+ * make, and record the quote that call returns via `record()`.
18
+ */
19
+ export type CouponQuoteFn = (code: string | null) => Promise<PillarDiscountQuote>;
20
+
21
+ /**
22
+ * The API's own message for a failed request.
23
+ *
24
+ * A coupon rejection comes back as a 400 whose body carries the reason ALREADY
25
+ * translated ("This coupon has expired", "Your order does not reach this
26
+ * coupon's minimum spend", …) — the server sends the rendered string, not the
27
+ * `errors.coupon.*` key, so there is nothing to map and nothing to invent.
28
+ * Replacing it with a house "invalid code" would throw away the only
29
+ * explanation the buyer is ever given.
30
+ */
31
+ export function apiErrorMessage(e: unknown): string | undefined {
32
+ return (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
33
+ }
34
+
35
+ /**
36
+ * The message to show for a failure: the API's own wording when the failure
37
+ * came from the server, otherwise a locally thrown precondition ("reserve a
38
+ * slot first"). A transport `Error` from axios carries a `response`, so its
39
+ * useless "Request failed with status code 400" is never surfaced.
40
+ */
41
+ export function couponFailureMessage(e: unknown, fallback: string): string {
42
+ const fromApi = apiErrorMessage(e);
43
+ if (fromApi) return fromApi;
44
+ const hasResponse = !!(e as { response?: unknown })?.response;
45
+ if (!hasResponse && e instanceof Error && e.message) return e.message;
46
+ return fallback;
47
+ }
48
+
49
+ export interface CouponField {
50
+ /** The raw code in the input (upper-cased by the UI, trimmed on send). */
51
+ code: string;
52
+ setCode: (code: string) => void;
53
+ /** Trimmed code, or `undefined` when the field is empty — send this. */
54
+ submittedCode: string | undefined;
55
+ /** Whether the "Have a discount code?" affordance has been expanded. */
56
+ showInput: boolean;
57
+ setShowInput: (show: boolean) => void;
58
+ /** The API's verbatim rejection message, or a local precondition message. */
59
+ error: string | null;
60
+ setError: (message: string | null) => void;
61
+ isApplying: boolean;
62
+ /** The server's last pricing answer — `null` until something has quoted. */
63
+ quote: PillarDiscountQuote | null;
64
+ /** Every discount that applied, entered OR automatic. */
65
+ appliedCoupons: AppliedDiscountCoupon[];
66
+ /** MAJOR units. `0` when nothing applied. */
67
+ discountAmount: number;
68
+ /** True once the server has confirmed a discount is on this checkout. */
69
+ hasDiscount: boolean;
70
+ /** Re-quote with the entered code. No-op without a quote function. */
71
+ apply: () => Promise<void>;
72
+ /** Re-quote with NO code, restoring the undiscounted total. */
73
+ remove: () => Promise<void>;
74
+ /** Record a quote produced by a call this field did not make. */
75
+ record: (quote: PillarDiscountQuote | null) => void;
76
+ /** Report a failure, taking the API's own wording. */
77
+ fail: (e: unknown, fallback?: string) => void;
78
+ /** Forget everything — used when the underlying record is discarded. */
79
+ reset: () => void;
80
+ /** Whether an Apply/Remove button should be offered at all. */
81
+ canQuote: boolean;
82
+ }
83
+
84
+ /**
85
+ * The discount-code field shared by every checkout: the input, the API's
86
+ * rejection message, and the server-computed quote that lets the summary show
87
+ * the buyer a Discount LINE rather than a total that silently shrank.
88
+ */
89
+ export function useCouponField(quoteFn?: CouponQuoteFn): CouponField {
90
+ const [code, setCode] = useState("");
91
+ const [showInput, setShowInput] = useState(false);
92
+ const [error, setError] = useState<string | null>(null);
93
+ const [isApplying, setIsApplying] = useState(false);
94
+ const [quote, setQuote] = useState<PillarDiscountQuote | null>(null);
95
+
96
+ const submittedCode = code.trim() ? code.trim() : undefined;
97
+
98
+ const fail = useCallback((e: unknown, fallback = "Something went wrong.") => {
99
+ setError(couponFailureMessage(e, fallback));
100
+ }, []);
101
+
102
+ const run = useCallback(
103
+ async (next: string | null) => {
104
+ if (!quoteFn) return;
105
+ setError(null);
106
+ setIsApplying(true);
107
+ try {
108
+ setQuote(await quoteFn(next));
109
+ } catch (e) {
110
+ // The failed code is NOT retained as applied: the previous quote stands
111
+ // (or none does), so the total on screen keeps matching the server.
112
+ setError(couponFailureMessage(e, "This code could not be applied."));
113
+ } finally {
114
+ setIsApplying(false);
115
+ }
116
+ },
117
+ [quoteFn],
118
+ );
119
+
120
+ const apply = useCallback(async () => {
121
+ const trimmed = code.trim();
122
+ if (!trimmed) return;
123
+ await run(trimmed);
124
+ }, [code, run]);
125
+
126
+ const remove = useCallback(async () => {
127
+ await run(null);
128
+ setCode("");
129
+ setShowInput(false);
130
+ }, [run]);
131
+
132
+ const record = useCallback((next: PillarDiscountQuote | null) => {
133
+ setError(null);
134
+ setQuote(next);
135
+ }, []);
136
+
137
+ const reset = useCallback(() => {
138
+ setCode("");
139
+ setShowInput(false);
140
+ setError(null);
141
+ setQuote(null);
142
+ }, []);
143
+
144
+ return {
145
+ code,
146
+ setCode,
147
+ submittedCode,
148
+ showInput,
149
+ setShowInput,
150
+ error,
151
+ setError,
152
+ isApplying,
153
+ quote,
154
+ appliedCoupons: quote?.appliedCoupons ?? [],
155
+ discountAmount: quote?.discountAmount ?? 0,
156
+ hasDiscount: (quote?.discountAmount ?? 0) > 0,
157
+ apply,
158
+ remove,
159
+ record,
160
+ fail,
161
+ reset,
162
+ canQuote: !!quoteFn,
163
+ };
164
+ }