@tribe-nest/forge 3.2.0 → 3.9.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/_tests/eventWaitlist.spec.ts +122 -0
- package/src/data/queries/_tests/passTransfers.spec.ts +89 -0
- package/src/data/queries/_tests/walletPass.spec.ts +159 -0
- 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/useEventWaitlist.ts +429 -0
- package/src/data/queries/useEvents.ts +15 -1
- package/src/data/queries/useMyBookings.ts +211 -0
- package/src/data/queries/useMyTickets.ts +154 -0
- package/src/data/queries/usePassTransfers.ts +318 -0
- package/src/data/queries/usePaymentFlow.ts +12 -0
- package/src/data/queries/useWalletPass.ts +236 -0
- package/src/data/queries/useWebsite.ts +6 -0
- package/src/index.ts +14 -0
- package/src/server/_tests/siteBootstrap.spec.ts +131 -0
- package/src/server/index.ts +122 -6
- package/src/types/diagnostics.ts +49 -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 +26 -0
- package/src/ui/shell/PoweredBy.tsx +62 -0
- package/src/ui/shell/PreviewDiagnostics.tsx +80 -0
- package/src/ui/shell/TribeNestApp.tsx +39 -1
- package/src/ui/shell/diagnosticsGating.spec.ts +102 -0
- package/src/ui/shell/diagnosticsGating.ts +90 -0
- package/src/ui/shell/shellGating.spec.ts +60 -1
- package/src/ui/shell/shellGating.ts +47 -0
- package/src/ui/styled/AccountDashboard.tsx +598 -1
- 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 +24 -5
- package/src/ui/styled/EventTickets.tsx +49 -5
- package/src/ui/styled/EventWaitlist.tsx +448 -0
- package/src/ui/styled/TicketTransfer.tsx +393 -0
- package/src/ui/styled/WalletPassButtons.tsx +208 -0
- package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
- package/src/ui/styled/_tests/WalletPassButtons.spec.tsx +223 -0
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
- 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 {
|
|
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,
|
|
@@ -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
|
+
}
|