@tribe-nest/forge 3.21.0 → 3.23.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 +2 -1
- package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
- package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
- package/src/data/queries/useAuthActions.ts +1 -1
- package/src/data/queries/useCheckouts.ts +5 -0
- package/src/data/queries/useEventWaitlist.ts +123 -5
- package/src/data/queries/useEvents.ts +82 -4
- package/src/data/queries/useMembership.ts +8 -2
- package/src/data/queries/useMyBookings.ts +37 -0
- package/src/data/queries/useMyTickets.ts +112 -0
- package/src/data/queries/useOrders.ts +10 -0
- package/src/data/queries/usePassTransfers.ts +73 -13
- package/src/data/queries/usePaymentFlow.ts +84 -18
- package/src/data/queries/useShipping.ts +5 -0
- package/src/data/queries/useSubscriptions.ts +50 -4
- package/src/index.ts +5 -0
- package/src/server/index.ts +52 -0
- package/src/server/platform.ts +1 -1
- package/src/server/platformEvents.generated.ts +33 -33
- package/src/server/pwa.ts +2 -2
- package/src/types/models.ts +203 -5
- package/src/types/paystack-inline.d.ts +45 -0
- package/src/ui/format/_tests/attendees.spec.ts +231 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
- package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
- package/src/ui/format/attendees.ts +187 -0
- package/src/ui/format/membershipGate.ts +209 -0
- package/src/ui/format/ticketAvailability.ts +226 -0
- package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
- package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
- package/src/ui/headless/checkout/inventoryHold.ts +83 -0
- package/src/ui/headless/checkout/useCheckout.ts +144 -8
- package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
- package/src/ui/headless/course/useCourseCheckout.ts +4 -0
- package/src/ui/headless/donation/Donation.tsx +10 -0
- package/src/ui/headless/donation/DonationContext.tsx +27 -1
- package/src/ui/headless/event/useEventCheckout.ts +203 -2
- package/src/ui/headless/event/usePresaleCode.ts +181 -0
- package/src/ui/headless/index.ts +25 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
- package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
- package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
- package/src/ui/headless/offer/Offer.tsx +19 -1
- package/src/ui/headless/offer/OfferContext.tsx +69 -1
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
- package/src/ui/headless/work/useWorkPortal.ts +3 -3
- package/src/ui/index.ts +64 -0
- package/src/ui/shell/diagnosticsGating.ts +3 -3
- package/src/ui/styled/AccountDashboard.tsx +143 -12
- package/src/ui/styled/AddToCalendar.tsx +34 -10
- package/src/ui/styled/AudioPlayer.tsx +1 -1
- package/src/ui/styled/BundleConfirmation.tsx +1 -1
- package/src/ui/styled/Checkout.tsx +42 -32
- package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
- package/src/ui/styled/CoachingBooking.tsx +11 -2
- package/src/ui/styled/CoachingConfirmation.tsx +7 -3
- package/src/ui/styled/CohortPage.tsx +1 -1
- package/src/ui/styled/ContactForm.tsx +1 -1
- package/src/ui/styled/CourseCheckout.tsx +10 -1
- package/src/ui/styled/CourseConfirmation.tsx +2 -2
- package/src/ui/styled/CourseDetail.tsx +30 -1
- package/src/ui/styled/DiscountCode.tsx +1 -1
- package/src/ui/styled/EmailListForm.tsx +1 -1
- package/src/ui/styled/EventConfirmation.tsx +3 -1
- package/src/ui/styled/EventDetail.tsx +53 -22
- package/src/ui/styled/EventSeriesDetail.tsx +1 -1
- package/src/ui/styled/EventTickets.tsx +284 -19
- package/src/ui/styled/EventWaitlist.tsx +23 -3
- package/src/ui/styled/HoldNotice.tsx +192 -0
- package/src/ui/styled/InvoicePayment.tsx +14 -5
- package/src/ui/styled/MembershipCheckout.tsx +20 -11
- package/src/ui/styled/MembershipGateNotice.tsx +159 -0
- package/src/ui/styled/OfferButton.tsx +24 -1
- package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
- package/src/ui/styled/PaystackPayButton.tsx +66 -0
- package/src/ui/styled/PresaleCode.tsx +174 -0
- package/src/ui/styled/ProductDetail.tsx +75 -5
- package/src/ui/styled/ProductGrid.tsx +26 -0
- package/src/ui/styled/ReviewForm.tsx +2 -2
- package/src/ui/styled/TicketTransfer.tsx +71 -42
- package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
- package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
- package/src/ui/styled/community/CommunityFeed.tsx +1 -1
- package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
- package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
- package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
- package/src/utils/_tests/presaleCode.spec.ts +168 -0
- package/src/utils/_tests/structuredData.spec.ts +275 -0
- package/src/utils/membershipAccess.ts +3 -3
- package/src/utils/paystackCheckout.ts +277 -0
- package/src/utils/presaleCode.ts +96 -0
- package/src/utils/structuredData.ts +361 -27
- package/src/utils/ticketOrderOutcome.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tribe-nest/forge",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.23.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"test:watch": "vitest"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
+
"@paystack/inline-js": "^2.24.0",
|
|
23
24
|
"@radix-ui/react-dialog": "^1.1.14",
|
|
24
25
|
"@radix-ui/react-slot": "^1.2.3",
|
|
25
26
|
"@stripe/react-stripe-js": "^3.8.0",
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
isLiveWaitlistEntry,
|
|
7
7
|
isTicketSoldOut,
|
|
8
8
|
rememberWaitlistEntry,
|
|
9
|
+
readWaitlistClaimFromUrl,
|
|
9
10
|
ticketSeatsRemaining,
|
|
10
11
|
} from "../useEventWaitlist";
|
|
11
12
|
import type { IEvent, ITicket } from "../../../types/models";
|
|
@@ -120,3 +121,40 @@ describe("the anonymous joiner's remembered capability ids", () => {
|
|
|
120
121
|
expect(getRememberedWaitlistEntries("e1")).toEqual([]);
|
|
121
122
|
});
|
|
122
123
|
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Events 2.6b — the offer email's claim link.
|
|
127
|
+
*
|
|
128
|
+
* `?waitlistEntry=<uuid>&waitlistClaim=<64 hex>`. The parse is deliberately
|
|
129
|
+
* strict about the token's shape so a junk query string never becomes a request,
|
|
130
|
+
* and a link missing either half is simply not a claim — the visitor gets the
|
|
131
|
+
* ordinary page rather than a broken one.
|
|
132
|
+
*/
|
|
133
|
+
describe("readWaitlistClaimFromUrl", () => {
|
|
134
|
+
const token = "a".repeat(64);
|
|
135
|
+
|
|
136
|
+
it("reads a well-formed claim", () => {
|
|
137
|
+
expect(readWaitlistClaimFromUrl(`?waitlistEntry=entry-1&waitlistClaim=${token}`)).toEqual({
|
|
138
|
+
entryId: "entry-1",
|
|
139
|
+
token,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("survives the other query parameters an email link picks up", () => {
|
|
144
|
+
expect(
|
|
145
|
+
readWaitlistClaimFromUrl(`?utm_source=email&waitlistClaim=${token}&waitlistEntry=entry-9&ref=x`),
|
|
146
|
+
).toEqual({ entryId: "entry-9", token });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("is null when either half is missing", () => {
|
|
150
|
+
expect(readWaitlistClaimFromUrl(`?waitlistClaim=${token}`)).toBeNull();
|
|
151
|
+
expect(readWaitlistClaimFromUrl("?waitlistEntry=entry-1")).toBeNull();
|
|
152
|
+
expect(readWaitlistClaimFromUrl("")).toBeNull();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("rejects a token that is not exactly 64 lowercase hex characters", () => {
|
|
156
|
+
for (const bad of ["nope", "A".repeat(64), "a".repeat(63), "a".repeat(65), "../../etc"]) {
|
|
157
|
+
expect(readWaitlistClaimFromUrl(`?waitlistEntry=entry-1&waitlistClaim=${bad}`)).toBeNull();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -3,18 +3,19 @@ import {
|
|
|
3
3
|
forgetPassTransfer,
|
|
4
4
|
getRememberedPassTransfers,
|
|
5
5
|
isPendingPassTransfer,
|
|
6
|
+
mergePendingTransfer,
|
|
6
7
|
rememberPassTransfer,
|
|
7
8
|
} from "../usePassTransfers";
|
|
8
|
-
import { myTicketPassIds } from "../useMyTickets";
|
|
9
|
+
import { myTicketHeldPassIds, myTicketPassIds, type MyTicket } from "../useMyTickets";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Events 2.2 — the pure decisions behind the buyer-facing transfer surface.
|
|
12
13
|
*
|
|
13
14
|
* Only the total functions are exercised; the hooks themselves are thin
|
|
14
15
|
* transport whose behaviour belongs to the API's own specs. The local store is
|
|
15
|
-
* the one thing here with real logic
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* the one thing here with real logic; it used to be the ONLY record a buyer had
|
|
17
|
+
* of their own send, and is now demoted to a bridge across the refetch that
|
|
18
|
+
* follows one, with `my-tickets`' `pendingTransfer` as the source of truth.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
const clear = () => {
|
|
@@ -87,3 +88,98 @@ describe("the ids a transfer can be addressed by", () => {
|
|
|
87
88
|
expect(myTicketPassIds({ items: [{ id: "i", quantity: 1, price: 10, ticketTitle: null }] })).toEqual([]);
|
|
88
89
|
});
|
|
89
90
|
});
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One live transfer per pass is a DATABASE guarantee (a partial unique index),
|
|
95
|
+
* so exactly one row may ever be drawn. Two would be a lie about the ticket's
|
|
96
|
+
* state and would offer two "withdraw" buttons for one thing.
|
|
97
|
+
*/
|
|
98
|
+
describe("mergePendingTransfer", () => {
|
|
99
|
+
const remembered = {
|
|
100
|
+
passId: "TN-1",
|
|
101
|
+
transferId: "local-1",
|
|
102
|
+
toEmail: "local@example.com",
|
|
103
|
+
expiresAt: "2026-04-01T00:00:00.000Z",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const fromServer = {
|
|
107
|
+
id: "server-1",
|
|
108
|
+
status: "pending",
|
|
109
|
+
toName: null,
|
|
110
|
+
toEmail: "server@example.com",
|
|
111
|
+
expiresAt: "2026-04-02T00:00:00.000Z",
|
|
112
|
+
createdAt: "2026-03-30T00:00:00.000Z",
|
|
113
|
+
window: { open: true, secondsRemaining: 3600, expiresAt: "2026-04-02T00:00:00.000Z" },
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
it("is null when nothing is in flight anywhere", () => {
|
|
117
|
+
expect(mergePendingTransfer(null, [])).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("THE POINT: the server's row is what a second device sees", () => {
|
|
121
|
+
// `localStorage` is empty here — this browser never sent anything. Before
|
|
122
|
+
// the API carried `pendingTransfer` this returned nothing, which is why a
|
|
123
|
+
// sender on another device had no transfer to withdraw.
|
|
124
|
+
const merged = mergePendingTransfer(fromServer, [])!;
|
|
125
|
+
|
|
126
|
+
expect(merged.transferId).toBe("server-1");
|
|
127
|
+
expect(merged.toEmail).toBe("server@example.com");
|
|
128
|
+
expect(merged.fromServer).toBe(true);
|
|
129
|
+
expect(merged.open).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("the server WINS over a stale local record, rather than drawing both", () => {
|
|
133
|
+
const merged = mergePendingTransfer(fromServer, [remembered])!;
|
|
134
|
+
expect(merged.transferId).toBe("server-1");
|
|
135
|
+
expect(merged.fromServer).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("falls back to the local bridge before the refetch lands", () => {
|
|
139
|
+
const merged = mergePendingTransfer(null, [remembered])!;
|
|
140
|
+
expect(merged.transferId).toBe("local-1");
|
|
141
|
+
expect(merged.fromServer).toBe(false);
|
|
142
|
+
// Optimistic: a just-sent transfer is live by construction, and the server
|
|
143
|
+
// corrects it on the very next read.
|
|
144
|
+
expect(merged.open).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("reports a lapsed-but-unswept transfer as closed, and still withdrawable", () => {
|
|
148
|
+
const lapsed = { ...fromServer, window: { open: false, secondsRemaining: 0, expiresAt: fromServer.expiresAt } };
|
|
149
|
+
const merged = mergePendingTransfer(lapsed, [])!;
|
|
150
|
+
|
|
151
|
+
// Hiding it would put the sender back where they started: a live row they
|
|
152
|
+
// cannot address. The row stays, the copy changes.
|
|
153
|
+
expect(merged.open).toBe(false);
|
|
154
|
+
expect(merged.transferId).toBe("server-1");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Which passes may an affordance be drawn on?
|
|
160
|
+
*
|
|
161
|
+
* Every pass-scoped endpoint — transfer, wallet, QR — resolves the current
|
|
162
|
+
* HOLDER, and a purchaser sees passes they do not hold: named guests at
|
|
163
|
+
* checkout, and (until the read was fixed) tickets already claimed by somebody
|
|
164
|
+
* else. A control on one of those could only ever answer 404.
|
|
165
|
+
*/
|
|
166
|
+
describe("myTicketHeldPassIds", () => {
|
|
167
|
+
const ticket = (passes: { id: string; isHolder?: boolean }[]) =>
|
|
168
|
+
({ items: [{ id: "item-1", quantity: passes.length, price: 10, ticketTitle: "GA", passes }] }) as unknown as MyTicket;
|
|
169
|
+
|
|
170
|
+
it("keeps only the passes the caller holds", () => {
|
|
171
|
+
const t = ticket([
|
|
172
|
+
{ id: "TN-1", isHolder: true },
|
|
173
|
+
{ id: "TN-2", isHolder: false },
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
expect(myTicketHeldPassIds(t)).toEqual(["TN-1"]);
|
|
177
|
+
// The unfiltered helper still reports everything on the order — the wallet
|
|
178
|
+
// and transfer controls are the things that need narrowing, not the list.
|
|
179
|
+
expect(myTicketPassIds(t)).toEqual(["TN-1", "TN-2"]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("treats an ABSENT flag as held, so an older API build is unchanged", () => {
|
|
183
|
+
expect(myTicketHeldPassIds(ticket([{ id: "TN-9" }]))).toEqual(["TN-9"]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -39,7 +39,7 @@ export function useForgotPassword() {
|
|
|
39
39
|
if (!contextId) {
|
|
40
40
|
// Better than posting without it: the endpoint would 400 on schema
|
|
41
41
|
// validation and the form would show "something went wrong".
|
|
42
|
-
throw new Error("useForgotPassword: no profileId
|
|
42
|
+
throw new Error("useForgotPassword: no profileId. Pass one or render inside a ForgeProvider with profileId");
|
|
43
43
|
}
|
|
44
44
|
const res = await client.post("/public/sessions/forgot-password", {
|
|
45
45
|
profileId: contextId,
|
|
@@ -143,7 +143,12 @@ export type ApplyCheckoutCouponResult = {
|
|
|
143
143
|
/** NET = max(0, subtotalCents − discountCents), minor units. */
|
|
144
144
|
totalCents: number;
|
|
145
145
|
appliedCoupons: AppliedBundleCoupon[];
|
|
146
|
+
/** STRIPE: the re-issued PaymentIntent client secret. */
|
|
146
147
|
paymentSecret: string;
|
|
148
|
+
/** PAYSTACK: the re-issued inline-checkout session. */
|
|
149
|
+
accessCode?: string;
|
|
150
|
+
/** PAYSTACK: the hosted page for the re-issued session (runtime fallback). */
|
|
151
|
+
checkoutUrl?: string;
|
|
147
152
|
paymentId: string;
|
|
148
153
|
chargedAmount: number;
|
|
149
154
|
chargedCurrency: string;
|
|
@@ -17,6 +17,10 @@ import type { IEvent, ITicket } from "../../types/models";
|
|
|
17
17
|
* POST /public/events/:eventId/waitlist join → WaitlistEntry (201)
|
|
18
18
|
* GET /public/events/:eventId/waitlist mine → WaitlistEntry[] (401 if anonymous)
|
|
19
19
|
* GET /public/events/:eventId/waitlist/:entryId one → WaitlistEntry
|
|
20
|
+
* …optionally `&claimToken=<64 hex>` — the offer email's credential (2.6b),
|
|
21
|
+
* which is the ONLY way an offered fan can read their place on a device
|
|
22
|
+
* that never joined and is not signed in. That is the device the email is
|
|
23
|
+
* opened on.
|
|
20
24
|
* DELETE /public/events/:eventId/waitlist/:entryId leave → { id, status, leftAt }
|
|
21
25
|
* ```
|
|
22
26
|
*
|
|
@@ -49,6 +53,22 @@ export type WaitlistOffer = {
|
|
|
49
53
|
secondsRemaining: number | null;
|
|
50
54
|
/** The instant the offer lapses — the single most important field here. */
|
|
51
55
|
claimExpiresAt: string | null;
|
|
56
|
+
/**
|
|
57
|
+
* Are the offered seats actually HELD for this person until the deadline
|
|
58
|
+
* (events 2.6b)?
|
|
59
|
+
*
|
|
60
|
+
* Distinct from `offerOpen`, and the distinction is the whole change. An offer
|
|
61
|
+
* used to reserve nothing — the fan raced the open page, and the copy said so.
|
|
62
|
+
* A reserved offer takes a real inventory hold for the length of the window,
|
|
63
|
+
* so the tier reads sold out to everybody else. Render "these are yours until
|
|
64
|
+
* Friday" ONLY when this is true; an unreserved offer (one made before the
|
|
65
|
+
* reservation existed, or one whose hold a failed payment released) is still a
|
|
66
|
+
* real offer with a real deadline and must keep the older, weaker promise.
|
|
67
|
+
*
|
|
68
|
+
* Optional so a site pinned to an older API build degrades to "not reserved"
|
|
69
|
+
* rather than claiming a hold that is not there.
|
|
70
|
+
*/
|
|
71
|
+
seatsReserved?: boolean;
|
|
52
72
|
};
|
|
53
73
|
|
|
54
74
|
export type WaitlistEntry = {
|
|
@@ -88,14 +108,26 @@ export const isLiveWaitlistEntry = (entry: Pick<WaitlistEntry, "status">): boole
|
|
|
88
108
|
// ── Where a join control may be DRAWN ────────────────────────────────────────
|
|
89
109
|
|
|
90
110
|
/**
|
|
91
|
-
* Seats
|
|
111
|
+
* Seats that are not SOLD — the waitlist's question, which is not the purchase
|
|
112
|
+
* UI's question.
|
|
92
113
|
*
|
|
93
114
|
* Deliberately ignores `held` (units inside somebody else's live checkout) even
|
|
94
115
|
* though the server's waitlist rule subtracts it. The difference only ever makes
|
|
95
116
|
* this stricter: `quantity - sold <= 0` implies `quantity - sold - held <= 0`,
|
|
96
117
|
* so a tier this calls sold out is always one the server will accept a join for.
|
|
97
|
-
*
|
|
98
|
-
*
|
|
118
|
+
*
|
|
119
|
+
* The purchase UI DOES subtract `held` — it has to, because that is what the
|
|
120
|
+
* server sells on (`ticketSeatsAvailable` in `ui/format/ticketAvailability`).
|
|
121
|
+
* The two are answering different things and the gap between them is deliberate:
|
|
122
|
+
*
|
|
123
|
+
* - a tier with held-but-unsold seats is un-addable and says "ON HOLD", and
|
|
124
|
+
* offers no waitlist, because the seats are coming back;
|
|
125
|
+
* - a tier with nothing unsold says "SOLD OUT" and offers the queue.
|
|
126
|
+
*
|
|
127
|
+
* Collapsing them would put a "join the waitlist" beside a tier that unfreezes
|
|
128
|
+
* five minutes later, and send a fan an email about a seat they could have had
|
|
129
|
+
* by waiting. Identical to `waitlistAvailableSeats` on the server only in shape,
|
|
130
|
+
* not in intent — that one gates OFFERS, this one gates where a join is drawn.
|
|
99
131
|
*/
|
|
100
132
|
export const ticketSeatsRemaining = (ticket: Pick<ITicket, "quantity" | "sold">): number =>
|
|
101
133
|
Math.max(0, (ticket.quantity ?? 0) - (ticket.sold ?? 0));
|
|
@@ -334,6 +366,16 @@ export function useEventWaitlistPlaces(eventId?: string, accountId?: string) {
|
|
|
334
366
|
const { client, profileId } = useForge();
|
|
335
367
|
const mine = useMyEventWaitlist(eventId, accountId);
|
|
336
368
|
const remembered = useRememberedWaitlistEntries(eventId);
|
|
369
|
+
/**
|
|
370
|
+
* 2.6b — a THIRD source: the offer the visitor arrived holding.
|
|
371
|
+
*
|
|
372
|
+
* The other two both assume continuity with the browser that joined — a
|
|
373
|
+
* session, or a remembered id in this localStorage. An offer email is read on
|
|
374
|
+
* a phone, and neither holds there, so the fan landed on an ordinary sold-out
|
|
375
|
+
* page with a "join the waitlist" form beside the seats already reserved for
|
|
376
|
+
* them. The token in the link is what makes that place readable.
|
|
377
|
+
*/
|
|
378
|
+
const claim = useEventWaitlistClaim(eventId);
|
|
337
379
|
|
|
338
380
|
const entryQueries = useQueries({
|
|
339
381
|
queries: remembered.map((item) => ({
|
|
@@ -359,15 +401,21 @@ export function useEventWaitlistPlaces(eventId?: string, accountId?: string) {
|
|
|
359
401
|
const entry = query.data;
|
|
360
402
|
if (entry && !byId.has(entry.id)) byId.set(entry.id, entry);
|
|
361
403
|
}
|
|
404
|
+
// Last, and unconditionally: an entry the link addresses is the one the
|
|
405
|
+
// visitor is here about. `set` rather than a has-check so the token-scoped
|
|
406
|
+
// read wins if the same row arrived by another route.
|
|
407
|
+
if (claim.entry) byId.set(claim.entry.id, claim.entry);
|
|
362
408
|
return [...byId.values()];
|
|
363
409
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
364
|
-
}, [mine.data, entryStamp]);
|
|
410
|
+
}, [mine.data, entryStamp, claim.entry]);
|
|
365
411
|
|
|
366
412
|
return {
|
|
367
413
|
entries,
|
|
368
414
|
/** Only the LIVE ones — what "am I on the waitlist?" means to a buyer. */
|
|
369
415
|
liveEntries: useMemo(() => entries.filter(isLiveWaitlistEntry), [entries]),
|
|
370
|
-
|
|
416
|
+
/** The claim this visitor arrived with, for the surface that spends it. */
|
|
417
|
+
claim,
|
|
418
|
+
isLoading: mine.isLoading || claim.isLoading || entryQueries.some((query) => query.isLoading),
|
|
371
419
|
};
|
|
372
420
|
}
|
|
373
421
|
|
|
@@ -427,3 +475,73 @@ export function useMyWaitlistPlaces(accountId?: string) {
|
|
|
427
475
|
isLoading: eventsLoading || listQueries.some((query) => query.isLoading),
|
|
428
476
|
};
|
|
429
477
|
}
|
|
478
|
+
|
|
479
|
+
// ── The offer email's claim link (2.6b) ──────────────────────────────────────
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* The claim credential an offer email hands out.
|
|
483
|
+
*
|
|
484
|
+
* The link used to be the ordinary public event page, which proved nothing about
|
|
485
|
+
* who was holding it — and once the offer started RESERVING seats it could not
|
|
486
|
+
* even be used, because the reservation makes the tier read sold out to
|
|
487
|
+
* everybody, the offeree included. The email now carries
|
|
488
|
+
* `?waitlistEntry=<id>&waitlistClaim=<64 hex>`: the id addresses the place, the
|
|
489
|
+
* token proves the holder and is what lets the reserved hold be spent.
|
|
490
|
+
*/
|
|
491
|
+
export type WaitlistClaimLink = { entryId: string; token: string };
|
|
492
|
+
|
|
493
|
+
/** 64 lowercase hex, matching the server's own shape gate. */
|
|
494
|
+
const isWellFormedClaimToken = (value: string | null): value is string =>
|
|
495
|
+
!!value && /^[0-9a-f]{64}$/.test(value);
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Read the claim out of the CURRENT url.
|
|
499
|
+
*
|
|
500
|
+
* Deliberately not stored anywhere. It is a bearer credential with a deadline,
|
|
501
|
+
* and the durable copy is the email; putting it in localStorage would leave a
|
|
502
|
+
* spendable token on a shared device long after the offer died, for no benefit
|
|
503
|
+
* — the fan re-opens the email.
|
|
504
|
+
*/
|
|
505
|
+
export const readWaitlistClaimFromUrl = (search?: string): WaitlistClaimLink | null => {
|
|
506
|
+
const raw = search ?? (typeof window === "undefined" ? "" : window.location.search);
|
|
507
|
+
if (!raw) return null;
|
|
508
|
+
const params = new URLSearchParams(raw);
|
|
509
|
+
const entryId = params.get("waitlistEntry");
|
|
510
|
+
const token = params.get("waitlistClaim");
|
|
511
|
+
if (!entryId || !isWellFormedClaimToken(token)) return null;
|
|
512
|
+
return { entryId, token };
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* The offer this visitor arrived holding, if they arrived holding one.
|
|
517
|
+
*
|
|
518
|
+
* `enabled` on the token means an ordinary visitor makes no request at all. A
|
|
519
|
+
* dead or foreign token 404s, which is an ANSWER — the offer is over, or the
|
|
520
|
+
* link belongs to another artist — so it is not retried and the caller simply
|
|
521
|
+
* renders the ordinary page.
|
|
522
|
+
*/
|
|
523
|
+
export function useEventWaitlistClaim(eventId?: string, search?: string) {
|
|
524
|
+
const { client, profileId } = useForge();
|
|
525
|
+
const link = useMemo(() => readWaitlistClaimFromUrl(search), [search]);
|
|
526
|
+
|
|
527
|
+
const query = useQuery<WaitlistEntry>({
|
|
528
|
+
queryKey: [...waitlistKey(eventId), "claim", link?.entryId, profileId],
|
|
529
|
+
queryFn: async () => {
|
|
530
|
+
const res = await client.get(`/public/events/${eventId}/waitlist/${link!.entryId}`, {
|
|
531
|
+
params: { profileId, claimToken: link!.token },
|
|
532
|
+
});
|
|
533
|
+
return res.data;
|
|
534
|
+
},
|
|
535
|
+
enabled: !!eventId && !!profileId && !!client && !!link,
|
|
536
|
+
retry: false,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
/** The raw token, to be posted with the order that spends the reservation. */
|
|
541
|
+
token: link?.token ?? null,
|
|
542
|
+
entry: query.data ?? null,
|
|
543
|
+
/** True only while the seats are genuinely held and the window is open. */
|
|
544
|
+
isClaimable: !!query.data?.offer?.offerOpen && !!query.data?.offer?.seatsReserved,
|
|
545
|
+
isLoading: query.isLoading,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
@@ -25,19 +25,48 @@ export function useEvents() {
|
|
|
25
25
|
* seed the query — the hook then returns data on first render with no loading
|
|
26
26
|
* state, so the page can be server-rendered for SEO with no client spinner.
|
|
27
27
|
*/
|
|
28
|
-
export function useEvent(
|
|
28
|
+
export function useEvent(
|
|
29
|
+
id?: string,
|
|
30
|
+
options?: {
|
|
31
|
+
initialData?: IEvent;
|
|
32
|
+
/**
|
|
33
|
+
* A presale code the buyer holds (1.2). A correct one reveals the hidden
|
|
34
|
+
* tiers it unlocks, in this same response, and sets `presale.codeAccepted`.
|
|
35
|
+
*
|
|
36
|
+
* Part of the query KEY, so entering a code refetches rather than serving
|
|
37
|
+
* the unrevealed cache — and so the seeded `initialData` (fetched without a
|
|
38
|
+
* code) is not mistaken for a coded response.
|
|
39
|
+
*/
|
|
40
|
+
accessCode?: string | null;
|
|
41
|
+
},
|
|
42
|
+
) {
|
|
29
43
|
const { client, profileId } = useForge();
|
|
44
|
+
const accessCode = options?.accessCode || undefined;
|
|
45
|
+
// Only callers that pass the option at all — presale-aware ones — get the
|
|
46
|
+
// hold-the-last-page behaviour below. Everyone else keeps the old semantics
|
|
47
|
+
// exactly, including "the event goes undefined while a DIFFERENT event
|
|
48
|
+
// loads", which is what a router navigation wants.
|
|
49
|
+
const presaleAware = !!options && "accessCode" in options;
|
|
30
50
|
|
|
31
51
|
return useQuery<IEvent>({
|
|
32
|
-
queryKey: ["event", id, profileId],
|
|
52
|
+
queryKey: ["event", id, profileId, accessCode ?? null],
|
|
33
53
|
queryFn: async () => {
|
|
34
54
|
const res = await client.get(`/public/events/${id}`, {
|
|
35
|
-
params: { profileId },
|
|
55
|
+
params: { profileId, ...(accessCode ? { accessCode } : {}) },
|
|
36
56
|
});
|
|
37
57
|
return res.data;
|
|
38
58
|
},
|
|
39
59
|
enabled: !!id && !!profileId && !!client,
|
|
40
|
-
|
|
60
|
+
// Seeding only applies to the uncoded query: `initialData` came from a
|
|
61
|
+
// loader that had no code, so handing it to a coded key would show an
|
|
62
|
+
// unrevealed tier list and claim the code was applied.
|
|
63
|
+
initialData: accessCode ? undefined : options?.initialData,
|
|
64
|
+
// Applying a code changes the query key, which would otherwise blank the
|
|
65
|
+
// whole event page back to a spinner while the coded read is in flight —
|
|
66
|
+
// the buyer presses Apply and the show they were reading disappears. Keep
|
|
67
|
+
// the last event on screen and let `isFetching` drive the field's own
|
|
68
|
+
// "Checking…".
|
|
69
|
+
placeholderData: presaleAware ? (previous?: IEvent) => previous : undefined,
|
|
41
70
|
});
|
|
42
71
|
}
|
|
43
72
|
|
|
@@ -49,6 +78,21 @@ export type CreateEventOrderInput = {
|
|
|
49
78
|
* the tier's own `price` — it can raise what is charged, never lower it.
|
|
50
79
|
*/
|
|
51
80
|
amounts?: Record<string, number>;
|
|
81
|
+
/**
|
|
82
|
+
* Who is actually coming, keyed by ticket id exactly as `items` is, one entry
|
|
83
|
+
* per seat of that tier in issue order (Events 2.1).
|
|
84
|
+
*
|
|
85
|
+
* Send it only when the event has `collectAttendeeDetails` on — the server
|
|
86
|
+
* does not read it otherwise. A list whose length does not equal that tier's
|
|
87
|
+
* quantity is REFUSED (a short list would leave a silent extra pass in the
|
|
88
|
+
* purchaser's name), so build it with `attendeesPayload` from
|
|
89
|
+
* `forge/ui`'s attendee helpers rather than by hand.
|
|
90
|
+
*
|
|
91
|
+
* `email` is accepted per attendee and deliberately not sent by either
|
|
92
|
+
* storefront: it is the pass's OWNER, so naming another address hands that
|
|
93
|
+
* seat away. Handing a ticket to somebody else is what transfer is for.
|
|
94
|
+
*/
|
|
95
|
+
attendees?: Record<string, { name: string; email?: string }[]>;
|
|
52
96
|
email: string;
|
|
53
97
|
firstName?: string;
|
|
54
98
|
lastName?: string;
|
|
@@ -60,6 +104,32 @@ export type CreateEventOrderInput = {
|
|
|
60
104
|
* the reason as its message and no order is created.
|
|
61
105
|
*/
|
|
62
106
|
couponCode?: string;
|
|
107
|
+
/**
|
|
108
|
+
* The PRESALE code (1.2) — not the discount code above. It buys access to a
|
|
109
|
+
* tier, it does not change a price.
|
|
110
|
+
*
|
|
111
|
+
* The sell guard runs against the tier's own column on every order, so this
|
|
112
|
+
* has to travel with the purchase and not merely with the read that revealed
|
|
113
|
+
* the tier: a buyer who unlocks a presale and then checks out without it gets
|
|
114
|
+
* their order refused after entering their card details.
|
|
115
|
+
*/
|
|
116
|
+
accessCode?: string;
|
|
117
|
+
/**
|
|
118
|
+
* The waitlist offer's claim token (events 2.6b) — 64 hex from the offer
|
|
119
|
+
* email's link, NOT a discount and NOT a presale code.
|
|
120
|
+
*
|
|
121
|
+
* It is a capability over inventory that is ALREADY reserved: the offer took
|
|
122
|
+
* a real hold when it was made, so presenting this makes the order spend that
|
|
123
|
+
* hold rather than acquire a new one. Without it an offeree cannot buy at
|
|
124
|
+
* all, because their own reservation is what makes the tier read sold out to
|
|
125
|
+
* them.
|
|
126
|
+
*
|
|
127
|
+
* The server refuses a claim it cannot honour (closed window, released
|
|
128
|
+
* reservation, a cart reaching past the offered tier) rather than quietly
|
|
129
|
+
* falling back to an ordinary purchase — so a refusal here is the truth about
|
|
130
|
+
* the offer, not a generic sold-out.
|
|
131
|
+
*/
|
|
132
|
+
waitlistClaimToken?: string;
|
|
63
133
|
};
|
|
64
134
|
|
|
65
135
|
export type CreateEventOrderResult = {
|
|
@@ -83,6 +153,14 @@ export type CreateEventOrderResult = {
|
|
|
83
153
|
couponId?: string | null;
|
|
84
154
|
/** Every discount that applied, entered OR automatic. */
|
|
85
155
|
appliedCoupons?: AppliedDiscountCoupon[];
|
|
156
|
+
/**
|
|
157
|
+
* When these tickets stop being reserved, ISO-8601.
|
|
158
|
+
*
|
|
159
|
+
* `null` for a free order — its hold has already converted by the time this
|
|
160
|
+
* returns, so a countdown on it would be counting something that no longer
|
|
161
|
+
* exists — and for a profile with inventory holds switched off.
|
|
162
|
+
*/
|
|
163
|
+
holdExpiresAt?: string | null;
|
|
86
164
|
};
|
|
87
165
|
|
|
88
166
|
/** Create an event ticket order (the step before start-payment). */
|
|
@@ -2,7 +2,13 @@ import type { MembershipTier } from "../../types/models";
|
|
|
2
2
|
import { useForge } from "../../provider/ForgeProvider";
|
|
3
3
|
import { useQuery } from "@tanstack/react-query";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* `opts.enabled` gates the request on top of the usual profile/client checks —
|
|
7
|
+
* for a caller that only needs tier NAMES when something is actually gated, and
|
|
8
|
+
* must not add a request to every ungated storefront. Defaults to on, so every
|
|
9
|
+
* existing caller is unchanged.
|
|
10
|
+
*/
|
|
11
|
+
export function useGetMembershipTiers(opts: { enabled?: boolean } = {}) {
|
|
6
12
|
const { client, profileId } = useForge();
|
|
7
13
|
|
|
8
14
|
return useQuery<MembershipTier[]>({
|
|
@@ -15,6 +21,6 @@ export function useGetMembershipTiers() {
|
|
|
15
21
|
});
|
|
16
22
|
return res.data;
|
|
17
23
|
},
|
|
18
|
-
enabled: !!profileId && !!client,
|
|
24
|
+
enabled: !!profileId && !!client && opts.enabled !== false,
|
|
19
25
|
});
|
|
20
26
|
}
|
|
@@ -108,11 +108,48 @@ export type MyBooking = {
|
|
|
108
108
|
coachingProductTimezone: string | null;
|
|
109
109
|
coachingProductDurationMinutes: number | null;
|
|
110
110
|
cancellation: BookingCancellation;
|
|
111
|
+
/** Already resolved server-side — see {@link BookingLocation}. */
|
|
112
|
+
location?: BookingLocation | null;
|
|
111
113
|
/** The server's answer. Never recomputed here. */
|
|
112
114
|
canReschedule: boolean;
|
|
113
115
|
rescheduleReason: RescheduleRefusal | null;
|
|
116
|
+
/**
|
|
117
|
+
* The session's `.ics`, both forms — what lets the portal offer Apple
|
|
118
|
+
* Calendar rather than only Google/Outlook/Microsoft 365. `null` unless the
|
|
119
|
+
* booking is confirmed.
|
|
120
|
+
*
|
|
121
|
+
* The address is stable across a RESCHEDULE (it is HMAC(kind:id) and the
|
|
122
|
+
* booking id does not change), which is the mechanism by which a re-fetch
|
|
123
|
+
* moves the entry already in the buyer's calendar instead of adding a second
|
|
124
|
+
* one. Optional: an older API build does not emit it.
|
|
125
|
+
*/
|
|
126
|
+
calendarUrl?: string | null;
|
|
127
|
+
calendarWebcalUrl?: string | null;
|
|
114
128
|
};
|
|
115
129
|
|
|
130
|
+
/**
|
|
131
|
+
* WHERE the session happens.
|
|
132
|
+
*
|
|
133
|
+
* The server has already resolved this — the booking's own override if the
|
|
134
|
+
* coach set one for this session, otherwise the coaching product's default —
|
|
135
|
+
* so there is exactly one value and no rule to re-derive in the browser. Two
|
|
136
|
+
* fields would eventually be rendered in the wrong order by one of the two
|
|
137
|
+
* rendering stacks, and the consequence of that is not cosmetic: it is a client
|
|
138
|
+
* dialling into an empty room.
|
|
139
|
+
*
|
|
140
|
+
* `null` means the coach has not said where the session is. Draw nothing; the
|
|
141
|
+
* confirmation email carries the same gap and inventing a placeholder ("Online")
|
|
142
|
+
* would promise a link that does not exist.
|
|
143
|
+
*
|
|
144
|
+
* Withheld for anything not `confirmed`: a cancelled session has no venue to go
|
|
145
|
+
* to and no call to join.
|
|
146
|
+
*
|
|
147
|
+
* Optional on the type because an older API build does not emit it.
|
|
148
|
+
*/
|
|
149
|
+
export type BookingLocation =
|
|
150
|
+
| { type: "in_person"; address: string }
|
|
151
|
+
| { type: "online"; url: string };
|
|
152
|
+
|
|
116
153
|
export type CancelBookingResult = {
|
|
117
154
|
bookingId: string;
|
|
118
155
|
/** True when money actually went back; false for a free or already-refunded session. */
|