@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
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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<
|
|
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();
|