@tribe-nest/forge 3.22.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/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 +16 -0
- package/src/data/queries/useMyBookings.ts +25 -0
- 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/platform.ts +1 -1
- package/src/server/platformEvents.generated.ts +33 -33
- package/src/server/pwa.ts +2 -2
- package/src/types/models.ts +52 -5
- package/src/types/paystack-inline.d.ts +45 -0
- package/src/ui/format/_tests/attendees.spec.ts +4 -4
- package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
- package/src/ui/format/attendees.ts +2 -2
- package/src/ui/format/ticketAvailability.ts +226 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
- package/src/ui/headless/checkout/useCheckout.ts +72 -8
- 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 +70 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
- package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
- package/src/ui/headless/offer/Offer.tsx +19 -1
- package/src/ui/headless/offer/OfferContext.tsx +14 -1
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
- package/src/ui/headless/work/useWorkPortal.ts +3 -3
- package/src/ui/index.ts +22 -0
- package/src/ui/shell/diagnosticsGating.ts +3 -3
- package/src/ui/styled/AccountDashboard.tsx +73 -4
- package/src/ui/styled/AudioPlayer.tsx +1 -1
- package/src/ui/styled/BundleConfirmation.tsx +1 -1
- package/src/ui/styled/Checkout.tsx +25 -32
- package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
- package/src/ui/styled/CoachingBooking.tsx +11 -2
- package/src/ui/styled/CoachingConfirmation.tsx +3 -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/DiscountCode.tsx +1 -1
- package/src/ui/styled/EmailListForm.tsx +1 -1
- package/src/ui/styled/EventConfirmation.tsx +1 -1
- package/src/ui/styled/EventSeriesDetail.tsx +1 -1
- package/src/ui/styled/EventTickets.tsx +135 -21
- package/src/ui/styled/EventWaitlist.tsx +23 -3
- package/src/ui/styled/HoldNotice.tsx +2 -2
- package/src/ui/styled/InvoicePayment.tsx +14 -5
- package/src/ui/styled/MembershipCheckout.tsx +20 -11
- package/src/ui/styled/OfferButton.tsx +1 -1
- package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
- package/src/ui/styled/PaystackPayButton.tsx +66 -0
- package/src/ui/styled/PresaleCode.tsx +1 -1
- package/src/ui/styled/ReviewForm.tsx +2 -2
- package/src/ui/styled/TicketTransfer.tsx +3 -3
- package/src/ui/styled/_tests/PresaleCode.spec.tsx +1 -1
- 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/membershipAccess.ts +3 -3
- package/src/utils/paystackCheckout.ts +277 -0
- 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
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -114,6 +114,22 @@ export type CreateEventOrderInput = {
|
|
|
114
114
|
* their order refused after entering their card details.
|
|
115
115
|
*/
|
|
116
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;
|
|
117
133
|
};
|
|
118
134
|
|
|
119
135
|
export type CreateEventOrderResult = {
|
|
@@ -108,6 +108,8 @@ 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;
|
|
@@ -125,6 +127,29 @@ export type MyBooking = {
|
|
|
125
127
|
calendarWebcalUrl?: string | null;
|
|
126
128
|
};
|
|
127
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
|
+
|
|
128
153
|
export type CancelBookingResult = {
|
|
129
154
|
bookingId: string;
|
|
130
155
|
/** True when money actually went back; false for a free or already-refunded session. */
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import { useEffect, useRef } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import type { PaymentFlowResult, PaymentStartResponse } from "../../types/models";
|
|
3
3
|
import { PaymentProviderName } from "../../types/models";
|
|
4
|
+
import {
|
|
5
|
+
hasPaystackSession,
|
|
6
|
+
openPaystackCheckout,
|
|
7
|
+
type PaystackCheckoutHandlers,
|
|
8
|
+
type PaystackCheckoutOutcome,
|
|
9
|
+
} from "../../utils/paystackCheckout";
|
|
4
10
|
import { useForge } from "../../provider/ForgeProvider";
|
|
5
11
|
import { useMutation } from "@tanstack/react-query";
|
|
6
12
|
|
|
@@ -15,42 +21,80 @@ export interface UsePaymentFlowOptions {
|
|
|
15
21
|
autoStart?: boolean;
|
|
16
22
|
/** Gate auto-start on extra readiness (default true). */
|
|
17
23
|
enabled?: boolean;
|
|
18
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Handle the Paystack checkout automatically once the charge starts: open the
|
|
26
|
+
* inline modal, falling back to the hosted page when it cannot open
|
|
27
|
+
* (default true). `false` means "I will drive it myself": call
|
|
28
|
+
* `openPaystackCheckout()` from a button.
|
|
29
|
+
*
|
|
30
|
+
* The name is historical (it used to mean "redirect to the hosted page") and
|
|
31
|
+
* is deliberately kept: renaming it would break compiling sites for no gain.
|
|
32
|
+
*/
|
|
19
33
|
paystackRedirect?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Paystack modal callbacks. Omit `onPaystackSuccess` to keep the redirect
|
|
36
|
+
* leg's behaviour, which is to navigate to `returnUrl`, and that is what leaves
|
|
37
|
+
* every finalise page unchanged.
|
|
38
|
+
*/
|
|
39
|
+
onPaystackSuccess?: PaystackCheckoutHandlers["onSuccess"];
|
|
40
|
+
/** The fan closed the modal. NOT a failure, so leave them able to retry. */
|
|
41
|
+
onPaystackDismiss?: PaystackCheckoutHandlers["onDismiss"];
|
|
42
|
+
onPaystackError?: PaystackCheckoutHandlers["onError"];
|
|
20
43
|
}
|
|
21
44
|
|
|
22
45
|
/**
|
|
23
46
|
* Starts a payment against any `/public/<domain>/.../start-payment` endpoint and
|
|
24
47
|
* normalizes the result: resolves the provider (the field is named differently
|
|
25
|
-
* per domain) and, for Paystack,
|
|
26
|
-
*
|
|
48
|
+
* per domain) and, for Paystack, opens the inline checkout modal so the fan
|
|
49
|
+
* pays without leaving the creator's site. The Stripe UI (Elements + confirm)
|
|
50
|
+
* stays in the app; this hook only owns the data.
|
|
27
51
|
*/
|
|
28
52
|
export function usePaymentFlow(opts: UsePaymentFlowOptions) {
|
|
29
53
|
const { client, profileId } = useForge();
|
|
30
54
|
const { path, body, returnUrl, autoStart = true, enabled = true, paystackRedirect = true } = opts;
|
|
31
55
|
const startedRef = useRef(false);
|
|
32
56
|
|
|
57
|
+
// Callbacks are read through a ref so a caller passing inline arrows does not
|
|
58
|
+
// re-trigger the auto-open effect on every render.
|
|
59
|
+
const handlersRef = useRef<PaystackCheckoutHandlers>({});
|
|
60
|
+
handlersRef.current = {
|
|
61
|
+
onSuccess: opts.onPaystackSuccess,
|
|
62
|
+
onDismiss: opts.onPaystackDismiss,
|
|
63
|
+
onError: opts.onPaystackError,
|
|
64
|
+
};
|
|
65
|
+
|
|
33
66
|
const mutation = useMutation<PaymentFlowResult, unknown, Record<string, unknown> | undefined>({
|
|
34
67
|
mutationFn: async (override) => {
|
|
35
68
|
// override wins — click-driven flows pass a just-created orderId/returnUrl.
|
|
36
69
|
const res = await client.post(path as string, { ...body, profileId, returnUrl, ...(override ?? {}) });
|
|
37
70
|
const data = res.data as PaymentStartResponse;
|
|
38
71
|
const provider = (data.paymentProvider ?? data.paymentProviderName) as PaymentProviderName | undefined;
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
paystackRedirect &&
|
|
42
|
-
provider === PaymentProviderName.Paystack &&
|
|
43
|
-
data.paymentSecret &&
|
|
44
|
-
typeof window !== "undefined"
|
|
45
|
-
) {
|
|
46
|
-
window.location.href = data.paymentSecret;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
72
|
return { ...data, provider };
|
|
50
73
|
},
|
|
51
74
|
});
|
|
52
75
|
|
|
53
76
|
const { mutate, mutateAsync } = mutation;
|
|
77
|
+
const result = mutation.data ?? null;
|
|
78
|
+
const isPaystack = result?.provider === PaymentProviderName.Paystack;
|
|
79
|
+
|
|
80
|
+
const session = useMemo(
|
|
81
|
+
() => ({ accessCode: result?.accessCode, checkoutUrl: result?.checkoutUrl, returnUrl }),
|
|
82
|
+
[result?.accessCode, result?.checkoutUrl, returnUrl],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Open the Paystack modal for the charge that has already been started.
|
|
87
|
+
* Safe to call again after a dismissal: the access code still opens the same
|
|
88
|
+
* session, which is what makes "closed it by accident" recoverable.
|
|
89
|
+
*/
|
|
90
|
+
const openPaystack = useCallback(async (): Promise<PaystackCheckoutOutcome> => {
|
|
91
|
+
if (!hasPaystackSession(session)) return "unavailable";
|
|
92
|
+
return openPaystackCheckout(session, {
|
|
93
|
+
onSuccess: handlersRef.current.onSuccess,
|
|
94
|
+
onDismiss: handlersRef.current.onDismiss,
|
|
95
|
+
onError: handlersRef.current.onError,
|
|
96
|
+
});
|
|
97
|
+
}, [session]);
|
|
54
98
|
|
|
55
99
|
useEffect(() => {
|
|
56
100
|
if (autoStart && enabled && path && returnUrl && profileId && !startedRef.current) {
|
|
@@ -60,11 +104,32 @@ export function usePaymentFlow(opts: UsePaymentFlowOptions) {
|
|
|
60
104
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
61
105
|
}, [autoStart, enabled, path, returnUrl, profileId]);
|
|
62
106
|
|
|
107
|
+
// Auto-open, once per started charge. Keyed on the charge rather than a bare
|
|
108
|
+
// boolean so a coupon that re-mints the session opens the NEW one, and a fan
|
|
109
|
+
// who dismissed the modal is not immediately re-interrupted by it.
|
|
110
|
+
const autoOpenedRef = useRef<string | null>(null);
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!paystackRedirect || !isPaystack) return;
|
|
113
|
+
const key = result?.paymentId || result?.accessCode;
|
|
114
|
+
if (!key || autoOpenedRef.current === key) return;
|
|
115
|
+
autoOpenedRef.current = key;
|
|
116
|
+
void openPaystack();
|
|
117
|
+
}, [paystackRedirect, isPaystack, result?.paymentId, result?.accessCode, openPaystack]);
|
|
118
|
+
|
|
63
119
|
return {
|
|
64
|
-
result
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
120
|
+
result,
|
|
121
|
+
/** STRIPE: the PaymentIntent client secret. Undefined on Paystack. */
|
|
122
|
+
clientSecret: result?.paymentSecret,
|
|
123
|
+
paymentId: result?.paymentId,
|
|
124
|
+
provider: result?.provider,
|
|
125
|
+
/** PAYSTACK: the inline checkout session. Undefined on Stripe. */
|
|
126
|
+
accessCode: result?.accessCode,
|
|
127
|
+
/** PAYSTACK: the hosted page used only when the modal cannot open. */
|
|
128
|
+
checkoutUrl: result?.checkoutUrl,
|
|
129
|
+
isPaystack,
|
|
130
|
+
/** True once there is a Paystack session a pay button can open. */
|
|
131
|
+
canOpenPaystack: hasPaystackSession(session),
|
|
132
|
+
openPaystackCheckout: openPaystack,
|
|
68
133
|
isStarting: mutation.isPending,
|
|
69
134
|
error: mutation.error,
|
|
70
135
|
/**
|
|
@@ -86,6 +151,7 @@ export function usePaymentFlow(opts: UsePaymentFlowOptions) {
|
|
|
86
151
|
*/
|
|
87
152
|
reset: () => {
|
|
88
153
|
startedRef.current = false;
|
|
154
|
+
autoOpenedRef.current = null;
|
|
89
155
|
mutation.reset();
|
|
90
156
|
},
|
|
91
157
|
};
|
|
@@ -82,7 +82,12 @@ export type ApplyCouponResult = {
|
|
|
82
82
|
discountAmount: number;
|
|
83
83
|
chargedAmount: number;
|
|
84
84
|
chargedCurrency: string;
|
|
85
|
+
/** STRIPE: the re-issued PaymentIntent client secret. */
|
|
85
86
|
paymentSecret?: string;
|
|
87
|
+
/** PAYSTACK: the re-issued inline-checkout session. */
|
|
88
|
+
accessCode?: string;
|
|
89
|
+
/** PAYSTACK: the hosted page for the re-issued session (runtime fallback). */
|
|
90
|
+
checkoutUrl?: string;
|
|
86
91
|
paymentId?: string;
|
|
87
92
|
/** Re-quoted sales tax for the re-issued payment intent (display only). */
|
|
88
93
|
taxQuote?: import("../../types/models").PublicTaxQuote;
|
|
@@ -16,11 +16,38 @@ export type CreateSubscriptionInput = {
|
|
|
16
16
|
attributionRefId?: string;
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* The two things subscribing can produce.
|
|
21
|
+
*
|
|
22
|
+
* A member with no live subscription gets a provider session to pay with:
|
|
23
|
+
* `clientSecret` on Stripe, `accessCode` + `checkoutUrl` on Paystack. A member
|
|
24
|
+
* who ALREADY subscribes to this artist is changing tier: the server moves their
|
|
25
|
+
* existing subscription onto the new price with proration, so there is nothing
|
|
26
|
+
* to pay right now: `requiresPayment` is false, `changed` is true and no
|
|
27
|
+
* session is issued. Branch on `requiresPayment`, never on the presence of a
|
|
28
|
+
* secret alone. A Paystack subscription has never had one, and reading its
|
|
29
|
+
* absence as "nothing to pay" hands out the tier for free.
|
|
30
|
+
*/
|
|
31
|
+
export type CreateSubscriptionResult = {
|
|
32
|
+
membershipId: string;
|
|
33
|
+
subscriptionId: string;
|
|
34
|
+
/** STRIPE ONLY. */
|
|
35
|
+
clientSecret?: string;
|
|
36
|
+
/** PAYSTACK ONLY: inline checkout session. */
|
|
37
|
+
accessCode?: string;
|
|
38
|
+
/** PAYSTACK ONLY: hosted page, the runtime fallback. */
|
|
39
|
+
checkoutUrl?: string;
|
|
40
|
+
currentPeriodStart?: string;
|
|
41
|
+
currentPeriodEnd?: string;
|
|
42
|
+
requiresPayment?: boolean;
|
|
43
|
+
changed?: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Subscribe to a paid tier, or move an existing subscription onto another one. */
|
|
20
47
|
export function useCreateSubscription() {
|
|
21
48
|
const { client, profileId } = useForge();
|
|
22
49
|
|
|
23
|
-
return useMutation<
|
|
50
|
+
return useMutation<CreateSubscriptionResult, unknown, CreateSubscriptionInput>({
|
|
24
51
|
mutationFn: async (body) => {
|
|
25
52
|
const res = await client.post("/public/payments/subscriptions", { ...body, profileId });
|
|
26
53
|
return res.data;
|
|
@@ -40,11 +67,30 @@ export function useCreateFreeSubscription() {
|
|
|
40
67
|
});
|
|
41
68
|
}
|
|
42
69
|
|
|
43
|
-
|
|
70
|
+
export type CancelMembershipResult = {
|
|
71
|
+
membershipId: string;
|
|
72
|
+
/** The status AFTER cancelling — still `active` while a paid period runs out. */
|
|
73
|
+
status: string;
|
|
74
|
+
/** True when the cancellation was scheduled rather than applied immediately. */
|
|
75
|
+
cancelAtPeriodEnd: boolean;
|
|
76
|
+
/** ISO date benefits actually stop. Null when they already have. */
|
|
77
|
+
accessUntil: string | null;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Cancel the member's membership.
|
|
82
|
+
*
|
|
83
|
+
* Cancelling a paid, paid-up membership does NOT take effect now: the server
|
|
84
|
+
* schedules it for the end of the period they have already paid for and the
|
|
85
|
+
* member keeps their benefits until `accessUntil`. Free tiers (and any
|
|
86
|
+
* membership with nothing paid up) end immediately. Works for every membership
|
|
87
|
+
* the member can still use — including one whose card is failing, which used to
|
|
88
|
+
* be rejected with an error the UI never showed.
|
|
89
|
+
*/
|
|
44
90
|
export function useCancelMembership() {
|
|
45
91
|
const { client, profileId } = useForge();
|
|
46
92
|
|
|
47
|
-
return useMutation<
|
|
93
|
+
return useMutation<CancelMembershipResult, unknown, { membershipId: string }>({
|
|
48
94
|
mutationFn: async ({ membershipId }) => {
|
|
49
95
|
const res = await client.post("/public/payments/subscriptions/cancel", {
|
|
50
96
|
membershipId,
|
package/src/index.ts
CHANGED
|
@@ -127,6 +127,11 @@ export * from "./utils/landing";
|
|
|
127
127
|
export * from "./utils/metaPixel";
|
|
128
128
|
export * from "./utils/cookieConsent";
|
|
129
129
|
export * from "./utils/structuredData";
|
|
130
|
+
// Paystack inline checkout: the ONE implementation of the popup, exported so a
|
|
131
|
+
// hand-rolled payment surface (or `apps/client`) drives it through the same
|
|
132
|
+
// lazy load, the same runtime fallback and the same return-leg navigation
|
|
133
|
+
// rather than growing a second copy.
|
|
134
|
+
export * from "./utils/paystackCheckout";
|
|
130
135
|
// The per-booking checkout credential + where it is kept across the payment
|
|
131
136
|
// redirect. Exported so a host that drives the booking endpoints itself can
|
|
132
137
|
// hold the secret the same way the built-in flows do.
|
package/src/server/platform.ts
CHANGED
|
@@ -376,7 +376,7 @@ export async function handlePlatformEvent(
|
|
|
376
376
|
),
|
|
377
377
|
budget,
|
|
378
378
|
`Handler for "${event.event}" took longer than ${budget}ms. Move the slow part into ` +
|
|
379
|
-
`enqueueAppJob() and return
|
|
379
|
+
`enqueueAppJob() and return. The platform's delivery attempt times out before this finishes.`,
|
|
380
380
|
);
|
|
381
381
|
} catch (err) {
|
|
382
382
|
// 500 so the platform retries. Writes already made carry keys derived from
|