@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.
Files changed (56) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +122 -0
  3. package/src/data/queries/_tests/passTransfers.spec.ts +89 -0
  4. package/src/data/queries/_tests/walletPass.spec.ts +159 -0
  5. package/src/data/queries/useCheckouts.ts +84 -1
  6. package/src/data/queries/useCoachingAvailability.ts +18 -3
  7. package/src/data/queries/useCourses.ts +42 -1
  8. package/src/data/queries/useEventWaitlist.ts +429 -0
  9. package/src/data/queries/useEvents.ts +15 -1
  10. package/src/data/queries/useMyBookings.ts +211 -0
  11. package/src/data/queries/useMyTickets.ts +154 -0
  12. package/src/data/queries/usePassTransfers.ts +318 -0
  13. package/src/data/queries/usePaymentFlow.ts +12 -0
  14. package/src/data/queries/useWalletPass.ts +236 -0
  15. package/src/data/queries/useWebsite.ts +6 -0
  16. package/src/index.ts +14 -0
  17. package/src/server/_tests/siteBootstrap.spec.ts +131 -0
  18. package/src/server/index.ts +122 -6
  19. package/src/types/diagnostics.ts +49 -0
  20. package/src/types/models.ts +66 -0
  21. package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
  22. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
  23. package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
  24. package/src/ui/headless/checkout/useCheckout.ts +156 -8
  25. package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
  26. package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
  27. package/src/ui/headless/coupon/useCouponField.ts +164 -0
  28. package/src/ui/headless/course/useCourseCheckout.ts +113 -18
  29. package/src/ui/headless/event/useEventCheckout.ts +53 -2
  30. package/src/ui/headless/index.ts +15 -0
  31. package/src/ui/index.ts +26 -0
  32. package/src/ui/shell/PoweredBy.tsx +62 -0
  33. package/src/ui/shell/PreviewDiagnostics.tsx +80 -0
  34. package/src/ui/shell/TribeNestApp.tsx +39 -1
  35. package/src/ui/shell/diagnosticsGating.spec.ts +102 -0
  36. package/src/ui/shell/diagnosticsGating.ts +90 -0
  37. package/src/ui/shell/shellGating.spec.ts +60 -1
  38. package/src/ui/shell/shellGating.ts +47 -0
  39. package/src/ui/styled/AccountDashboard.tsx +598 -1
  40. package/src/ui/styled/AddToCalendar.tsx +104 -0
  41. package/src/ui/styled/Checkout.tsx +45 -14
  42. package/src/ui/styled/CoachingBooking.tsx +28 -8
  43. package/src/ui/styled/CoachingConfirmation.tsx +12 -0
  44. package/src/ui/styled/CourseCheckout.tsx +49 -18
  45. package/src/ui/styled/DiscountCode.tsx +206 -0
  46. package/src/ui/styled/EventConfirmation.tsx +68 -22
  47. package/src/ui/styled/EventDetail.tsx +24 -5
  48. package/src/ui/styled/EventTickets.tsx +49 -5
  49. package/src/ui/styled/EventWaitlist.tsx +448 -0
  50. package/src/ui/styled/TicketTransfer.tsx +393 -0
  51. package/src/ui/styled/WalletPassButtons.tsx +208 -0
  52. package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
  53. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
  54. package/src/ui/styled/_tests/WalletPassButtons.spec.tsx +223 -0
  55. package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
  56. package/src/utils/ticketOrderOutcome.ts +125 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.2.0",
3
+ "version": "3.9.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it, beforeEach } from "vitest";
2
+ import {
3
+ forgetWaitlistEntry,
4
+ getRememberedWaitlistEntries,
5
+ isEventWaitlistOpen,
6
+ isLiveWaitlistEntry,
7
+ isTicketSoldOut,
8
+ rememberWaitlistEntry,
9
+ ticketSeatsRemaining,
10
+ } from "../useEventWaitlist";
11
+ import type { IEvent, ITicket } from "../../../types/models";
12
+
13
+ /**
14
+ * Events 2.6 — the pure decisions behind the buyer-facing waitlist.
15
+ *
16
+ * Only the total functions are exercised here; everything else in the module is
17
+ * a thin transport whose behaviour belongs to the API's own specs.
18
+ */
19
+
20
+ const tier = (overrides: Partial<ITicket> = {}): ITicket =>
21
+ ({
22
+ id: "t1",
23
+ title: "Standing",
24
+ description: "",
25
+ price: 25,
26
+ quantity: 100,
27
+ order: 0,
28
+ sold: 0,
29
+ maxPerPerson: 4,
30
+ ...overrides,
31
+ }) as ITicket;
32
+
33
+ describe("ticketSeatsRemaining", () => {
34
+ it("counts what is left, floored at zero", () => {
35
+ expect(ticketSeatsRemaining(tier({ quantity: 100, sold: 40 }))).toBe(60);
36
+ expect(ticketSeatsRemaining(tier({ quantity: 100, sold: 100 }))).toBe(0);
37
+ // Oversold (a refund race, a manual capacity cut) is still zero, never negative.
38
+ expect(ticketSeatsRemaining(tier({ quantity: 100, sold: 120 }))).toBe(0);
39
+ });
40
+ });
41
+
42
+ describe("isTicketSoldOut — where a join control may be drawn", () => {
43
+ it("is false while anything is left to sell", () => {
44
+ expect(isTicketSoldOut(tier({ quantity: 10, sold: 9 }))).toBe(false);
45
+ });
46
+
47
+ it("is true once nothing is left", () => {
48
+ expect(isTicketSoldOut(tier({ quantity: 10, sold: 10 }))).toBe(true);
49
+ });
50
+
51
+ it("never offers a queue for an archived tier", () => {
52
+ expect(isTicketSoldOut(tier({ quantity: 10, sold: 10, archivedAt: "2026-01-01T00:00:00Z" }))).toBe(false);
53
+ });
54
+
55
+ it("never offers a queue once the sale window has closed", () => {
56
+ const now = new Date("2026-08-01T12:00:00Z");
57
+ // Waiting for a release that can no longer be sold is waiting for an email
58
+ // that will never arrive.
59
+ expect(isTicketSoldOut(tier({ quantity: 10, sold: 10, expiresAt: "2026-07-31T00:00:00Z" }), now)).toBe(false);
60
+ expect(isTicketSoldOut(tier({ quantity: 10, sold: 10, expiresAt: "2026-08-02T00:00:00Z" }), now)).toBe(true);
61
+ });
62
+ });
63
+
64
+ describe("isEventWaitlistOpen", () => {
65
+ const withStatus = (status: IEvent["status"]) => ({ status });
66
+
67
+ it("accepts the statuses a sign-up can still mean something on", () => {
68
+ expect(isEventWaitlistOpen(withStatus("scheduled"))).toBe(true);
69
+ expect(isEventWaitlistOpen(withStatus("on_sale"))).toBe(true);
70
+ expect(isEventWaitlistOpen(withStatus("sold_out"))).toBe(true);
71
+ });
72
+
73
+ it("refuses a show that is over or called off", () => {
74
+ expect(isEventWaitlistOpen(withStatus("cancelled"))).toBe(false);
75
+ expect(isEventWaitlistOpen(withStatus("completed"))).toBe(false);
76
+ expect(isEventWaitlistOpen(withStatus("draft"))).toBe(false);
77
+ });
78
+ });
79
+
80
+ describe("isLiveWaitlistEntry", () => {
81
+ it("counts a held offer as being on the list", () => {
82
+ expect(isLiveWaitlistEntry({ status: "waiting" })).toBe(true);
83
+ // An open offer IS a place in the queue, just a better one.
84
+ expect(isLiveWaitlistEntry({ status: "notified" })).toBe(true);
85
+ });
86
+
87
+ it("excludes every terminal status", () => {
88
+ expect(isLiveWaitlistEntry({ status: "converted" })).toBe(false);
89
+ expect(isLiveWaitlistEntry({ status: "left" })).toBe(false);
90
+ expect(isLiveWaitlistEntry({ status: "expired" })).toBe(false);
91
+ });
92
+ });
93
+
94
+ describe("the anonymous joiner's remembered capability ids", () => {
95
+ beforeEach(() => {
96
+ for (const entry of getRememberedWaitlistEntries()) forgetWaitlistEntry(entry.entryId);
97
+ });
98
+
99
+ it("remembers an entry and narrows by event", () => {
100
+ rememberWaitlistEntry({ eventId: "e1", entryId: "a" });
101
+ rememberWaitlistEntry({ eventId: "e2", entryId: "b" });
102
+
103
+ expect(getRememberedWaitlistEntries().map((e) => e.entryId)).toEqual(["a", "b"]);
104
+ expect(getRememberedWaitlistEntries("e1").map((e) => e.entryId)).toEqual(["a"]);
105
+ });
106
+
107
+ it("does not duplicate a re-join of the same entry", () => {
108
+ // Joining is idempotent server-side, so the SAME id comes back a second
109
+ // time — it must not become two places in the UI.
110
+ rememberWaitlistEntry({ eventId: "e1", entryId: "a" });
111
+ rememberWaitlistEntry({ eventId: "e1", entryId: "a" });
112
+
113
+ expect(getRememberedWaitlistEntries("e1")).toHaveLength(1);
114
+ });
115
+
116
+ it("forgets an entry once it is left", () => {
117
+ rememberWaitlistEntry({ eventId: "e1", entryId: "a" });
118
+ forgetWaitlistEntry("a");
119
+
120
+ expect(getRememberedWaitlistEntries("e1")).toEqual([]);
121
+ });
122
+ });
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it, beforeEach } from "vitest";
2
+ import {
3
+ forgetPassTransfer,
4
+ getRememberedPassTransfers,
5
+ isPendingPassTransfer,
6
+ rememberPassTransfer,
7
+ } from "../usePassTransfers";
8
+ import { myTicketPassIds } from "../useMyTickets";
9
+
10
+ /**
11
+ * Events 2.2 — the pure decisions behind the buyer-facing transfer surface.
12
+ *
13
+ * Only the total functions are exercised; the hooks themselves are thin
14
+ * transport whose behaviour belongs to the API's own specs. The local store is
15
+ * the one thing here with real logic, and it exists because there is NO
16
+ * buyer-readable list endpoint — the transfer history is operator-only — so a
17
+ * bug in it is a pending transfer the sender can never withdraw.
18
+ */
19
+
20
+ const clear = () => {
21
+ for (const entry of getRememberedPassTransfers()) forgetPassTransfer(entry.transferId);
22
+ };
23
+
24
+ beforeEach(clear);
25
+
26
+ describe("isPendingPassTransfer", () => {
27
+ it("is true only for the one status a cancel can act on", () => {
28
+ expect(isPendingPassTransfer({ status: "pending" })).toBe(true);
29
+ for (const status of ["claimed", "cancelled", "expired"]) {
30
+ expect(isPendingPassTransfer({ status })).toBe(false);
31
+ }
32
+ });
33
+ });
34
+
35
+ describe("the remembered-send store", () => {
36
+ it("keeps a send and reads it back, narrowed to its pass", () => {
37
+ rememberPassTransfer({ passId: "TN-1", transferId: "a", toEmail: "x@y.com", expiresAt: null });
38
+ rememberPassTransfer({ passId: "TN-2", transferId: "b", toEmail: "z@y.com", expiresAt: null });
39
+
40
+ expect(getRememberedPassTransfers()).toHaveLength(2);
41
+ expect(getRememberedPassTransfers("TN-1")).toEqual([
42
+ { passId: "TN-1", transferId: "a", toEmail: "x@y.com", expiresAt: null },
43
+ ]);
44
+ });
45
+
46
+ it("keeps at most ONE row per pass — the database allows no more", () => {
47
+ // A partial unique index enforces one live transfer per pass, so a second
48
+ // remembered row for the same pass could only ever be stale. The newest
49
+ // send wins; a stale id would offer a cancel that answers 400.
50
+ rememberPassTransfer({ passId: "TN-1", transferId: "old", toEmail: "x@y.com", expiresAt: null });
51
+ rememberPassTransfer({ passId: "TN-1", transferId: "new", toEmail: "q@y.com", expiresAt: null });
52
+
53
+ expect(getRememberedPassTransfers("TN-1")).toEqual([
54
+ { passId: "TN-1", transferId: "new", toEmail: "q@y.com", expiresAt: null },
55
+ ]);
56
+ });
57
+
58
+ it("forgets by transfer id and leaves the others alone", () => {
59
+ rememberPassTransfer({ passId: "TN-1", transferId: "a", toEmail: "x@y.com", expiresAt: null });
60
+ rememberPassTransfer({ passId: "TN-2", transferId: "b", toEmail: "z@y.com", expiresAt: null });
61
+
62
+ forgetPassTransfer("a");
63
+
64
+ expect(getRememberedPassTransfers().map((entry) => entry.transferId)).toEqual(["b"]);
65
+ // Forgetting something that was never there is a no-op, not a throw — a
66
+ // cancel whose row had already been dropped must still resolve.
67
+ expect(() => forgetPassTransfer("a")).not.toThrow();
68
+ });
69
+ });
70
+
71
+ describe("the ids a transfer can be addressed by", () => {
72
+ it("only ever sends a TN-… pass id, which is what the endpoint accepts", () => {
73
+ // The send endpoint is `/public/events/passes/:passId/transfers` and 404s on
74
+ // an order or order-item id. `myTicketPassIds` is the only source the portal
75
+ // has, so this is the contract the affordance is drawn from.
76
+ const ids = myTicketPassIds({
77
+ items: [
78
+ { id: "order-item-uuid", quantity: 2, price: 25, ticketTitle: "GA", passes: [{ id: "TN-11" }, { id: "TN-12" }] },
79
+ { id: "another-item", quantity: 1, price: 25, ticketTitle: "GA", passes: [{ id: "not-a-pass" }] },
80
+ ],
81
+ });
82
+
83
+ expect(ids).toEqual(["TN-11", "TN-12"]);
84
+ });
85
+
86
+ it("yields nothing when the API build emits no passes — draw no affordance", () => {
87
+ expect(myTicketPassIds({ items: [{ id: "i", quantity: 1, price: 10, ticketTitle: null }] })).toEqual([]);
88
+ });
89
+ });
@@ -0,0 +1,159 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { hasWalletPass, isEventPassId, type WalletPassStatus } from "../useWalletPass";
3
+ import { myTicketPassIds, type MyTicket } from "../useMyTickets";
4
+
5
+ /**
6
+ * Events 2.3 — the pure decisions behind the wallet affordance.
7
+ *
8
+ * `hasWalletPass` is the single predicate that decides whether a buyer ever
9
+ * learns the feature exists, so it gets exhaustive coverage of the ways it must
10
+ * answer NO. Everything else in the module is transport, and belongs to the
11
+ * API's own specs.
12
+ */
13
+
14
+ const status = (overrides: Partial<WalletPassStatus> = {}): WalletPassStatus => ({
15
+ passId: "TN-1001",
16
+ available: true,
17
+ reason: null,
18
+ apple: { available: true, downloadUrl: "/public/events/passes/TN-1001/wallet/apple?profileId=p1" },
19
+ google: { available: true, saveUrl: "https://pay.google.com/gp/v/save/jwt" },
20
+ ...overrides,
21
+ });
22
+
23
+ describe("hasWalletPass", () => {
24
+ it("is false for anything that is not a settled, positive answer", () => {
25
+ // No answer yet, or a query that 401'd / 404'd / failed. Absence of data is
26
+ // absence of the feature — never a placeholder.
27
+ expect(hasWalletPass(undefined)).toBe(false);
28
+ expect(hasWalletPass(null)).toBe(false);
29
+ });
30
+
31
+ it("is false for the state production is actually in today", () => {
32
+ // No signing credentials configured. This is a 200 — a successful "the
33
+ // feature is off" — and it must be indistinguishable from the feature not
34
+ // existing at all.
35
+ expect(
36
+ hasWalletPass(
37
+ status({
38
+ available: false,
39
+ reason: "not_configured",
40
+ apple: { available: false, downloadUrl: null },
41
+ google: { available: false, saveUrl: null },
42
+ }),
43
+ ),
44
+ ).toBe(false);
45
+ });
46
+
47
+ it("is false for every other refusal the API can give", () => {
48
+ for (const reason of ["rotating_qr", "order_not_active"] as const) {
49
+ expect(
50
+ hasWalletPass(
51
+ status({
52
+ available: false,
53
+ reason,
54
+ apple: { available: false, downloadUrl: null },
55
+ google: { available: false, saveUrl: null },
56
+ }),
57
+ ),
58
+ ).toBe(false);
59
+ }
60
+ });
61
+
62
+ it("is false when the API says available but neither provider produced anything", () => {
63
+ // Belt and braces: a provider can throw AFTER the credential gate passed
64
+ // (a certificate that parses but will not sign), and the API degrades the
65
+ // two providers independently. `available` alone is not enough to draw on.
66
+ expect(
67
+ hasWalletPass(
68
+ status({ apple: { available: false, downloadUrl: null }, google: { available: false, saveUrl: null } }),
69
+ ),
70
+ ).toBe(false);
71
+ });
72
+
73
+ it("is true as soon as EITHER provider has something real", () => {
74
+ expect(hasWalletPass(status({ google: { available: false, saveUrl: null } }))).toBe(true);
75
+ expect(hasWalletPass(status({ apple: { available: false, downloadUrl: null } }))).toBe(true);
76
+ expect(hasWalletPass(status())).toBe(true);
77
+ });
78
+ });
79
+
80
+ describe("isEventPassId", () => {
81
+ it("accepts only `TN-` plus digits, matching the API's own guard", () => {
82
+ expect(isEventPassId("TN-1")).toBe(true);
83
+ expect(isEventPassId("TN-000000123")).toBe(true);
84
+ });
85
+
86
+ it("rejects the ids a caller is most likely to reach for by mistake", () => {
87
+ // An order id and an order-item id are what `useMyTickets` actually hands
88
+ // back; sending either would be a guaranteed 404.
89
+ expect(isEventPassId("0f9c8b7a-1111-2222-3333-444455556666")).toBe(false);
90
+ expect(isEventPassId("TN-")).toBe(false);
91
+ expect(isEventPassId("TN-12a")).toBe(false);
92
+ expect(isEventPassId("tn-12")).toBe(false);
93
+ expect(isEventPassId(" TN-12")).toBe(false);
94
+ expect(isEventPassId(undefined)).toBe(false);
95
+ expect(isEventPassId(null)).toBe(false);
96
+ expect(isEventPassId(12)).toBe(false);
97
+ });
98
+ });
99
+
100
+ const ticket = (overrides: Partial<MyTicket> = {}): MyTicket =>
101
+ ({
102
+ id: "order-1",
103
+ items: [],
104
+ ...overrides,
105
+ }) as MyTicket;
106
+
107
+ describe("myTicketPassIds", () => {
108
+ it("is empty when the payload carries no passes at all", () => {
109
+ // An older API build, before `/public/events/my-tickets` nested passes
110
+ // under each item. The affordance must simply not appear rather than error.
111
+ expect(
112
+ myTicketPassIds(
113
+ ticket({ items: [{ id: "item-1", quantity: 2, price: 10, ticketTitle: "General Admission" }] }),
114
+ ),
115
+ ).toEqual([]);
116
+ });
117
+
118
+ it("reads pass ids from the order level", () => {
119
+ expect(myTicketPassIds(ticket({ passes: [{ id: "TN-1" }, { id: "TN-2" }] }))).toEqual(["TN-1", "TN-2"]);
120
+ });
121
+
122
+ it("reads pass ids nested under items — the shape the API actually emits", () => {
123
+ expect(
124
+ myTicketPassIds(
125
+ ticket({
126
+ items: [
127
+ { id: "item-1", quantity: 1, price: 10, ticketTitle: "GA", passes: [{ id: "TN-3" }] },
128
+ { id: "item-2", quantity: 1, price: 20, ticketTitle: "VIP", passes: [{ id: "TN-4" }] },
129
+ ],
130
+ }),
131
+ ),
132
+ ).toEqual(["TN-3", "TN-4"]);
133
+ });
134
+
135
+ it("de-duplicates across both shapes, so a payload carrying both draws one set of buttons", () => {
136
+ expect(
137
+ myTicketPassIds(
138
+ ticket({
139
+ passes: [{ id: "TN-5" }],
140
+ items: [{ id: "item-1", quantity: 1, price: 10, ticketTitle: "GA", passes: [{ id: "TN-5" }] }],
141
+ }),
142
+ ),
143
+ ).toEqual(["TN-5"]);
144
+ });
145
+
146
+ it("drops anything that is not a pass id rather than sending a request that must 404", () => {
147
+ expect(
148
+ myTicketPassIds(
149
+ ticket({
150
+ passes: [{ id: "order-1" }, { id: "" }, { id: "TN-6" }] as never,
151
+ }),
152
+ ),
153
+ ).toEqual(["TN-6"]);
154
+ });
155
+
156
+ it("survives a payload with no items array at all", () => {
157
+ expect(myTicketPassIds({ items: undefined as never, passes: undefined })).toEqual([]);
158
+ });
159
+ });
@@ -1,5 +1,6 @@
1
1
  import { useMutation } from "@tanstack/react-query";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
+ import { bundleCouponRequestBody } from "../../ui/headless/checkout/bundleCoupon";
3
4
  import type { CartItem, TicketCartItem } from "../../contexts/CartContext";
4
5
 
5
6
  /** One line of a bundle, in the shape `POST /public/checkouts` expects. */
@@ -69,7 +70,35 @@ export function cartToCheckoutLines(cartItems: CartItem[], ticketItems: TicketCa
69
70
  return [...ticketLines, ...productLines];
70
71
  }
71
72
 
72
- export type CreateCheckoutResult = { checkoutId: string; currency: string; subtotalCents: number };
73
+ /**
74
+ * One discount that actually came off a bundle — entered or automatic.
75
+ *
76
+ * `discountAmount` is in MAJOR units, unlike everything else the bundle endpoint
77
+ * returns, because it is the same shape every other pillar's checkout answers
78
+ * with. Naming the coupon is what lets an AUTOMATIC bundle discount render as
79
+ * "SUMMER10 — $6.00 off" rather than an unexplained reduction; before the
80
+ * response carried this, only `discountCents` and a `couponId` came back and
81
+ * there is no public `couponId` → code lookup.
82
+ */
83
+ export type AppliedBundleCoupon = {
84
+ code: string;
85
+ discountKind: string;
86
+ /** MAJOR units. */
87
+ discountAmount: number;
88
+ };
89
+
90
+ /** Note the units: the bundle endpoint is the only checkout answering in MINOR units. */
91
+ export type CreateCheckoutResult = {
92
+ checkoutId: string;
93
+ currency: string;
94
+ /** GROSS, minor units. (Beware: `start-payment` returns a NET `subtotalCents`.) */
95
+ subtotalCents: number;
96
+ discountCents?: number;
97
+ couponId?: string | null;
98
+ /** NET = max(0, subtotalCents − discountCents), minor units. */
99
+ totalCents?: number;
100
+ appliedCoupons?: AppliedBundleCoupon[];
101
+ };
73
102
 
74
103
  export function useCreateCheckout() {
75
104
  const { client, profileId } = useForge();
@@ -82,6 +111,60 @@ export function useCreateCheckout() {
82
111
  });
83
112
  }
84
113
 
114
+ /**
115
+ * What `POST /public/checkouts/apply-coupon` answers with.
116
+ *
117
+ * The payment fields are populated only when the bundle had already started
118
+ * payment: applying re-prices the children AND re-mints the intent, so the
119
+ * caller swaps its payment element onto the new secret rather than re-running
120
+ * start-payment. An empty `paymentSecret` means there was no intent to replace.
121
+ */
122
+ export type ApplyCheckoutCouponResult = {
123
+ checkoutId: string;
124
+ currency: string;
125
+ /** GROSS, minor units — what the goods cost undiscounted. */
126
+ subtotalCents: number;
127
+ discountCents: number;
128
+ couponId: string | null;
129
+ /** NET = max(0, subtotalCents − discountCents), minor units. */
130
+ totalCents: number;
131
+ appliedCoupons: AppliedBundleCoupon[];
132
+ paymentSecret: string;
133
+ paymentId: string;
134
+ chargedAmount: number;
135
+ chargedCurrency: string;
136
+ /** True when the re-price took the bundle to zero — it is already settled. */
137
+ isFreeCheckout: boolean;
138
+ };
139
+
140
+ /**
141
+ * Apply — or REMOVE — a discount code on a bundle that already exists.
142
+ *
143
+ * Omitting `couponCode` removes whatever is on it and restores the original
144
+ * total. This is the bundle's counterpart to `/public/orders/apply-coupon`, and
145
+ * it is why the code field on a bundle no longer has to freeze the moment the
146
+ * checkout is created.
147
+ */
148
+ export function useApplyCheckoutCoupon() {
149
+ const { client, profileId } = useForge();
150
+
151
+ return useMutation<
152
+ ApplyCheckoutCouponResult,
153
+ unknown,
154
+ { checkoutId: string; returnUrl: string; couponCode?: string }
155
+ >({
156
+ mutationFn: async ({ checkoutId, returnUrl, couponCode }) => {
157
+ // The body is built by `bundleCouponRequestBody` so the "remove sends no
158
+ // code at all" rule is asserted in one place rather than trusted here.
159
+ const res = await client.post(
160
+ "/public/checkouts/apply-coupon",
161
+ bundleCouponRequestBody({ profileId, checkoutId, returnUrl, couponCode }),
162
+ );
163
+ return res.data;
164
+ },
165
+ });
166
+ }
167
+
85
168
  export type FinalizeCheckoutResult = {
86
169
  checkoutId: string;
87
170
  status: string;
@@ -1,4 +1,4 @@
1
- import type { BookingSlot } from "../../types/models";
1
+ import type { BookingSlot, PillarDiscountQuote } from "../../types/models";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
3
  import { useMutation, useQuery } from "@tanstack/react-query";
4
4
 
@@ -40,13 +40,28 @@ export type UpdateCoachingBookingInput = {
40
40
  confirmIfFree?: boolean;
41
41
  questionnaire?: unknown;
42
42
  attributionRefId?: string;
43
+ /** Omit (or send `undefined`) to CLEAR a previously applied code. */
44
+ couponCode?: string;
43
45
  };
44
46
 
45
- /** Attach buyer details to a reserved coaching booking (confirms it if free). */
47
+ export type UpdateCoachingBookingResult = PillarDiscountQuote & {
48
+ bookingId: string;
49
+ isConfirmed: boolean;
50
+ };
51
+
52
+ /**
53
+ * Attach buyer details to a reserved coaching booking (confirms it if free) and
54
+ * (re-)price it.
55
+ *
56
+ * This doubles as the coaching pillar's quote: the gross price is re-derived
57
+ * from the PRODUCT on every call, so applying and then removing a discount code
58
+ * returns the buyer to exactly the original total instead of compounding off an
59
+ * already-netted figure. Only a still-`reserved` booking is re-priced.
60
+ */
46
61
  export function useUpdateCoachingBooking(productId?: string) {
47
62
  const { client } = useForge();
48
63
 
49
- return useMutation<{ bookingId: string; isConfirmed: boolean }, unknown, UpdateCoachingBookingInput>({
64
+ return useMutation<UpdateCoachingBookingResult, unknown, UpdateCoachingBookingInput>({
50
65
  mutationFn: async (body) => {
51
66
  const res = await client.post(`/public/coaching/products/${productId}/booking/update`, body);
52
67
  return res.data;
@@ -1,4 +1,4 @@
1
- import type { PaginatedData, PublicCourse } from "../../types/models";
1
+ import type { PaginatedData, PillarDiscountQuote, PublicCourse } from "../../types/models";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
3
  import { useMutation, useQuery } from "@tanstack/react-query";
4
4
 
@@ -10,6 +10,47 @@ export type CreateCourseBookingInput = {
10
10
  attributionRefId?: string;
11
11
  };
12
12
 
13
+ export type UpdateCourseBookingInput = {
14
+ bookingId: string;
15
+ email: string;
16
+ firstName: string;
17
+ lastName: string;
18
+ /** Confirm the booking outright when nothing is left to charge. */
19
+ confirmIfFree?: boolean;
20
+ questionnaire?: unknown;
21
+ attributionRefId?: string;
22
+ /** Omit (or send `undefined`) to CLEAR a previously applied code. */
23
+ couponCode?: string;
24
+ };
25
+
26
+ export type UpdateCourseBookingResult = PillarDiscountQuote & {
27
+ bookingId: string;
28
+ isConfirmed: boolean;
29
+ };
30
+
31
+ /**
32
+ * Attach buyer details to a course booking and (re-)price it.
33
+ *
34
+ * This is the course pillar's de-facto quote: it re-derives the gross price
35
+ * from the COURSE on every call — never from the booking's already-netted total
36
+ * — specifically so it can be fired repeatedly as the buyer edits the form.
37
+ * That is what makes apply/remove of a discount code safe here, and why
38
+ * removing one restores the original total rather than compounding.
39
+ *
40
+ * Only a still-`reserved` booking is re-priced; a confirmed one keeps the
41
+ * coupon and total it was actually charged at.
42
+ */
43
+ export function useUpdateCourseBooking(courseId?: string) {
44
+ const { client, profileId } = useForge();
45
+
46
+ return useMutation<UpdateCourseBookingResult, unknown, UpdateCourseBookingInput>({
47
+ mutationFn: async (body) => {
48
+ const res = await client.post(`/public/courses/${courseId}/booking/update`, { profileId, ...body });
49
+ return res.data;
50
+ },
51
+ });
52
+ }
53
+
13
54
  /** Create a course booking (buyer details → bookingId; free courses skip payment). */
14
55
  export function useCreateCourseBooking(courseId?: string) {
15
56
  const { client, profileId } = useForge();