@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
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { useMemo, useState } from "react";
|
|
2
2
|
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
3
3
|
import { useCart } from "../../../contexts/CartContext";
|
|
4
|
-
import {
|
|
4
|
+
import { useCreateEventOrder } from "../../../data/queries/useEvents";
|
|
5
5
|
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
6
6
|
import { useCouponField } from "../coupon/useCouponField";
|
|
7
|
+
import { usePresaleCode } from "./usePresaleCode";
|
|
8
|
+
import { useEventWaitlistClaim } from "../../../data/queries/useEventWaitlist";
|
|
9
|
+
import {
|
|
10
|
+
buildAttendeeSlots,
|
|
11
|
+
setAttendeeName,
|
|
12
|
+
validateAttendeeNames,
|
|
13
|
+
attendeesPayload,
|
|
14
|
+
collectsAttendees,
|
|
15
|
+
buyerFullName,
|
|
16
|
+
type AttendeeNames,
|
|
17
|
+
} from "../../format/attendees";
|
|
18
|
+
import { holdExpiredMessage, isHoldExpiredError } from "../checkout/inventoryHold";
|
|
19
|
+
import { useInventoryHold } from "../checkout/useInventoryHold";
|
|
7
20
|
import { computeBookingFeeAmount } from "../../format/bookingFee";
|
|
8
21
|
import { isPayWhatYouWant, pwywDefaultAmount, resolveUnitPrice, ticketSubtotals } from "../../format/pwyw";
|
|
22
|
+
import { parseMembershipGateError, type MembershipGateRefusal } from "../../format/membershipGate";
|
|
9
23
|
import { readAttributionRef } from "../../../utils/attribution";
|
|
10
24
|
import { readLanding } from "../../../utils/landing";
|
|
11
25
|
|
|
@@ -36,9 +50,40 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
36
50
|
const { setTickets, hasTicketsFor } = useCart();
|
|
37
51
|
// The detail is resolved by slug; the order is created against the real event
|
|
38
52
|
// id (the orders endpoint looks the event up by id, not slug).
|
|
39
|
-
|
|
53
|
+
// Read THROUGH the presale field rather than calling `useEvent` directly: it
|
|
54
|
+
// is the same React Query entry, but it is the one fetched with whatever
|
|
55
|
+
// presale code this buyer has applied, so `event.tickets` already contains the
|
|
56
|
+
// tiers that code unlocked. Calling `useEvent(slug)` here instead would give a
|
|
57
|
+
// tier list that silently omits what the buyer just unlocked access to.
|
|
58
|
+
const presale = usePresaleCode(slug);
|
|
59
|
+
const { event, isLoading } = presale;
|
|
40
60
|
const eventId = event?.id;
|
|
41
61
|
const createOrder = useCreateEventOrder(eventId);
|
|
62
|
+
/**
|
|
63
|
+
* Events 2.6b — the waitlist offer this visitor arrived holding, if any.
|
|
64
|
+
*
|
|
65
|
+
* Read here rather than passed in because the whole flow needs it: the tier
|
|
66
|
+
* the offer covers must be ADDABLE (the offeree's own reservation is what
|
|
67
|
+
* makes it read sold out), and the order must carry the token that spends the
|
|
68
|
+
* reservation instead of trying to take a new one. Null for every ordinary
|
|
69
|
+
* visitor, and no request is made for them.
|
|
70
|
+
*/
|
|
71
|
+
const waitlistClaim = useEventWaitlistClaim(eventId);
|
|
72
|
+
/**
|
|
73
|
+
* Seats of `held` that belong to THIS visitor, by tier.
|
|
74
|
+
*
|
|
75
|
+
* Handed to `maxAddableTickets` / `ticketSeatState` so the picker lets the
|
|
76
|
+
* offeree add exactly what was reserved for them and not one seat more. The
|
|
77
|
+
* server re-derives the same number from the hold it issued, so a tampered
|
|
78
|
+
* value buys nothing.
|
|
79
|
+
*/
|
|
80
|
+
const reservedForYou = useMemo<Record<string, number>>(
|
|
81
|
+
() =>
|
|
82
|
+
waitlistClaim.isClaimable && waitlistClaim.entry
|
|
83
|
+
? { [waitlistClaim.entry.eventTicketId]: waitlistClaim.entry.quantity }
|
|
84
|
+
: {},
|
|
85
|
+
[waitlistClaim.isClaimable, waitlistClaim.entry],
|
|
86
|
+
);
|
|
42
87
|
const flow = usePaymentFlow({ path: `/public/events/${eventId}/start-payment`, autoStart: false });
|
|
43
88
|
|
|
44
89
|
const [step, setStep] = useState<EventCheckoutStep>("tickets");
|
|
@@ -53,8 +98,31 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
53
98
|
const [lastName, setLastName] = useState(user?.lastName ?? "");
|
|
54
99
|
const [email, setEmail] = useState(user?.email ?? "");
|
|
55
100
|
const [questionnaire, setQuestionnaire] = useState<unknown>(undefined);
|
|
101
|
+
/**
|
|
102
|
+
* ticketId → per-seat names, positional.
|
|
103
|
+
*
|
|
104
|
+
* `collectAttendeeDetails` is an event setting an artist turns on for a
|
|
105
|
+
* guest-list show. It shipped with an admin toggle, a backend that accepts a
|
|
106
|
+
* per-ticket attendees map, and NO storefront asking for anything — so every
|
|
107
|
+
* pass printed the buyer's name and the door list was wrong, with nothing
|
|
108
|
+
* explaining why the setting did nothing. Both stacks render this component,
|
|
109
|
+
* so collecting it here is what makes the toggle real.
|
|
110
|
+
*/
|
|
111
|
+
const [attendeeNames, setAttendeeNames] = useState<AttendeeNames>({});
|
|
56
112
|
const [returnUrl, setReturnUrl] = useState("");
|
|
57
113
|
const [error, setError] = useState<string | null>(null);
|
|
114
|
+
/**
|
|
115
|
+
* A `MEMBERSHIP_GATE` refusal, STRUCTURED — which tier, and whether the buyer
|
|
116
|
+
* needs to sign in or to join.
|
|
117
|
+
*
|
|
118
|
+
* The gate should have been visible on the tier long before this (the read
|
|
119
|
+
* announces it), so reaching here means either the buyer's membership lapsed
|
|
120
|
+
* mid-checkout or the page rendered from a stale cache. Either way the useful
|
|
121
|
+
* response is the badge's: name the tier and offer the way in. Kept as state
|
|
122
|
+
* rather than a message because a sentence alone turns a silent failure into a
|
|
123
|
+
* visible dead end.
|
|
124
|
+
*/
|
|
125
|
+
const [gateRefusal, setGateRefusal] = useState<MembershipGateRefusal | null>(null);
|
|
58
126
|
/**
|
|
59
127
|
* The booking fee the SERVER charged, once an order exists.
|
|
60
128
|
*
|
|
@@ -232,11 +300,32 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
232
300
|
|
|
233
301
|
const continueToPayment = async () => {
|
|
234
302
|
setError(null);
|
|
303
|
+
setGateRefusal(null);
|
|
235
304
|
if (!slug || !eventId) return;
|
|
236
305
|
if (!firstName.trim() || !lastName.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
237
306
|
setError("Please enter your name and a valid email.");
|
|
238
307
|
return;
|
|
239
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Spend the reservation only when this order IS the claim.
|
|
311
|
+
*
|
|
312
|
+
* The offered tier, alone in the cart, at no more than the offered
|
|
313
|
+
* quantity — the same shape `resolveWaitlistClaim` accepts. Checking it
|
|
314
|
+
* here rather than sending the token blindly means a fan who adds a second
|
|
315
|
+
* tier gets an ordinary (and possibly successful) purchase instead of a
|
|
316
|
+
* claim rejection, which is the friendlier of the two truths.
|
|
317
|
+
*/
|
|
318
|
+
const claimedTierId = waitlistClaim.entry?.eventTicketId;
|
|
319
|
+
const cartTiers = Object.keys(selectedTickets).filter((id) => (selectedTickets[id] ?? 0) > 0);
|
|
320
|
+
const waitlistClaimToken =
|
|
321
|
+
waitlistClaim.isClaimable &&
|
|
322
|
+
claimedTierId &&
|
|
323
|
+
cartTiers.length === 1 &&
|
|
324
|
+
cartTiers[0] === claimedTierId &&
|
|
325
|
+
(selectedTickets[claimedTierId] ?? 0) <= (waitlistClaim.entry?.quantity ?? 0)
|
|
326
|
+
? waitlistClaim.token
|
|
327
|
+
: null;
|
|
328
|
+
|
|
240
329
|
try {
|
|
241
330
|
const data = await createOrder.mutateAsync({
|
|
242
331
|
items: selectedTickets,
|
|
@@ -247,7 +336,27 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
247
336
|
firstName,
|
|
248
337
|
lastName,
|
|
249
338
|
questionnaire,
|
|
339
|
+
// Omitted entirely when the event does not collect them, so an ordinary
|
|
340
|
+
// purchase posts the body it always did.
|
|
341
|
+
...(attendeesPayload(attendeeSlots) ? { attendees: attendeesPayload(attendeeSlots) } : {}),
|
|
250
342
|
couponCode: coupon.submittedCode,
|
|
343
|
+
// The presale code, not a discount. The sell guard re-checks it on the
|
|
344
|
+
// ORDER, so it has to travel with the purchase and not just with the
|
|
345
|
+
// read that revealed the tier — otherwise a buyer unlocks a presale,
|
|
346
|
+
// fills in their details, and is refused at the card step for want of
|
|
347
|
+
// the code they already typed. Omitted when there is none, so an
|
|
348
|
+
// ordinary purchase posts the body it always did.
|
|
349
|
+
...(presale.code ? { accessCode: presale.code } : {}),
|
|
350
|
+
/**
|
|
351
|
+
* The waitlist claim (2.6b). Sent ONLY when the cart is exactly the
|
|
352
|
+
* offered tier: the server refuses a claim whose cart reaches past it,
|
|
353
|
+
* and sending one anyway would turn "you also added a T-shirt tier"
|
|
354
|
+
* into a claim failure rather than an ordinary purchase.
|
|
355
|
+
*
|
|
356
|
+
* Omitted entirely otherwise, so an ordinary purchase posts the body it
|
|
357
|
+
* always did.
|
|
358
|
+
*/
|
|
359
|
+
...(waitlistClaimToken ? { waitlistClaimToken } : {}),
|
|
251
360
|
attributionRefId: readAttributionRef() ?? undefined,
|
|
252
361
|
...(readLanding() ?? {}),
|
|
253
362
|
});
|
|
@@ -279,10 +388,36 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
279
388
|
if (result.provider && result.provider !== "stripe") return; // Paystack redirected
|
|
280
389
|
setStep("payment");
|
|
281
390
|
} catch (e) {
|
|
391
|
+
/**
|
|
392
|
+
* A members-only tier the buyer cannot have. Checked BEFORE the coupon
|
|
393
|
+
* branch: a buyer who happened to type a discount code would otherwise be
|
|
394
|
+
* told the CODE was refused and sent to fix a code that was never the
|
|
395
|
+
* problem.
|
|
396
|
+
*/
|
|
397
|
+
const gate = parseMembershipGateError(e);
|
|
398
|
+
if (gate) {
|
|
399
|
+
setGateRefusal(gate);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
282
402
|
// A refused code fails the whole request, so with one entered the message
|
|
283
403
|
// belongs next to the field the buyer just used. The message is the API's
|
|
284
404
|
// own — "This coupon has expired", "…does not reach this coupon's minimum
|
|
285
405
|
// spend" — never a house "invalid code" that hides which it was.
|
|
406
|
+
/**
|
|
407
|
+
* The reservation lapsed mid-payment (409 `INVENTORY_HOLD_EXPIRED`) —
|
|
408
|
+
* NOT sold out, and emphatically not a bad coupon. Checked before the
|
|
409
|
+
* coupon branch for the same reason the gate is: a buyer who happened to
|
|
410
|
+
* type a discount code would otherwise be sent to fix a code that was
|
|
411
|
+
* never the problem, while the one action that works — retry — is not
|
|
412
|
+
* offered at all.
|
|
413
|
+
*
|
|
414
|
+
* Event ticket holds are taken UNCONDITIONALLY (no feature switch), so
|
|
415
|
+
* unlike the product cart this path is live for every profile.
|
|
416
|
+
*/
|
|
417
|
+
if (isHoldExpiredError(e)) {
|
|
418
|
+
setError(holdExpiredMessage(e, "Your ticket reservation expired before payment finished. Please try again."));
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
286
421
|
if (coupon.submittedCode) coupon.fail(e);
|
|
287
422
|
else setError(errMessage(e));
|
|
288
423
|
}
|
|
@@ -306,9 +441,47 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
306
441
|
setStep("details");
|
|
307
442
|
};
|
|
308
443
|
|
|
444
|
+
/**
|
|
445
|
+
* One slot per seat, in cart order, with the buyer pre-filled into the first.
|
|
446
|
+
* Empty when the event does not ask — so a normal event renders and posts
|
|
447
|
+
* exactly what it always did.
|
|
448
|
+
*/
|
|
449
|
+
/**
|
|
450
|
+
* The reservation clock. `undefined` on an older API and `null` when no hold
|
|
451
|
+
* was taken or the order is already paid — both render nothing, i.e. exactly
|
|
452
|
+
* the behaviour before holds existed.
|
|
453
|
+
*/
|
|
454
|
+
const holdExpiresAt = (flow.result as { holdExpiresAt?: string | null } | undefined)?.holdExpiresAt ?? null;
|
|
455
|
+
const hold = useInventoryHold(holdExpiresAt);
|
|
456
|
+
|
|
457
|
+
const attendeeSlots = useMemo(
|
|
458
|
+
() =>
|
|
459
|
+
collectsAttendees(event)
|
|
460
|
+
? buildAttendeeSlots({
|
|
461
|
+
tickets: (event?.tickets ?? []).filter((t) => (selectedTickets[t.id] ?? 0) > 0),
|
|
462
|
+
quantities: selectedTickets,
|
|
463
|
+
names: attendeeNames,
|
|
464
|
+
buyerName: buyerFullName({ firstName, lastName }),
|
|
465
|
+
})
|
|
466
|
+
: [],
|
|
467
|
+
[event, selectedTickets, attendeeNames, firstName, lastName],
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
const attendeeCheck = useMemo(() => validateAttendeeNames(attendeeSlots), [attendeeSlots]);
|
|
471
|
+
|
|
472
|
+
const setAttendee = (ticketId: string, index: number, value: string) =>
|
|
473
|
+
setAttendeeNames((prev) => setAttendeeName(prev, ticketId, index, value));
|
|
474
|
+
|
|
309
475
|
return {
|
|
310
476
|
event,
|
|
311
477
|
isLoading,
|
|
478
|
+
/** Countdown on the ticket reservation. `active:false` when there is none. */
|
|
479
|
+
hold,
|
|
480
|
+
/** Per-seat name inputs. Empty array when the event does not collect them. */
|
|
481
|
+
attendeeSlots,
|
|
482
|
+
setAttendee,
|
|
483
|
+
/** `{ok:false}` while a required seat name is missing or too long. */
|
|
484
|
+
attendeeCheck,
|
|
312
485
|
step,
|
|
313
486
|
setStep,
|
|
314
487
|
selectedTickets,
|
|
@@ -354,6 +527,22 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
354
527
|
/** Discount code state + the server's quote. See `useCouponField`. */
|
|
355
528
|
coupon,
|
|
356
529
|
clearCoupon,
|
|
530
|
+
/**
|
|
531
|
+
* Presale-code state (1.2) — a DIFFERENT code from `coupon`: it buys access
|
|
532
|
+
* to a tier, it does not change a price. `presale.visible` is false on every
|
|
533
|
+
* event without a coded tier, so a surface can render it unconditionally.
|
|
534
|
+
*/
|
|
535
|
+
presale,
|
|
536
|
+
/**
|
|
537
|
+
* Events 2.6b — the waitlist offer this visitor arrived holding.
|
|
538
|
+
*
|
|
539
|
+
* `reservedForYou` is the per-tier seat count a surface must pass to
|
|
540
|
+
* `maxAddableTickets` / `ticketSeatState`, or the offeree cannot add the
|
|
541
|
+
* seat that is being held for them. `waitlistClaim.isClaimable` is what a
|
|
542
|
+
* surface renders "your tickets are reserved until…" off.
|
|
543
|
+
*/
|
|
544
|
+
waitlistClaim,
|
|
545
|
+
reservedForYou,
|
|
357
546
|
/** Authoritative sales-tax quote from start-payment (display only). */
|
|
358
547
|
taxQuote: flow.result?.taxQuote ?? null,
|
|
359
548
|
firstName,
|
|
@@ -370,8 +559,20 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
370
559
|
ticketsInCart: event ? hasTicketsFor(event.id) : false,
|
|
371
560
|
continueToPayment,
|
|
372
561
|
clientSecret: flow.clientSecret,
|
|
562
|
+
/** Paystack: the started inline-checkout session and the way back into it. */
|
|
563
|
+
isPaystack: flow.isPaystack,
|
|
564
|
+
canOpenPaystack: flow.canOpenPaystack,
|
|
565
|
+
openPaystackCheckout: flow.openPaystackCheckout,
|
|
373
566
|
returnUrl,
|
|
374
567
|
isProcessing: createOrder.isPending || flow.isStarting,
|
|
375
568
|
error,
|
|
569
|
+
/**
|
|
570
|
+
* A `MEMBERSHIP_GATE` refusal, structured — pass it to
|
|
571
|
+
* `<MembershipGateNotice refusal={…}>` (or `useMembershipGateNotice`) rather
|
|
572
|
+
* than printing `error`, which is deliberately NOT set for this case: the
|
|
573
|
+
* buyer needs the tier's name and a way in, and a generic red line gives
|
|
574
|
+
* them neither.
|
|
575
|
+
*/
|
|
576
|
+
gateRefusal,
|
|
376
577
|
};
|
|
377
578
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import type { IEvent } from "../../../types/models";
|
|
3
|
+
import { useEvent } from "../../../data/queries/useEvents";
|
|
4
|
+
import {
|
|
5
|
+
readPresaleCode,
|
|
6
|
+
readPresaleCodeFromUrl,
|
|
7
|
+
subscribePresaleCode,
|
|
8
|
+
writePresaleCode,
|
|
9
|
+
} from "../../../utils/presaleCode";
|
|
10
|
+
|
|
11
|
+
export type PresaleCodeStatus = "idle" | "checking" | "accepted" | "rejected";
|
|
12
|
+
|
|
13
|
+
export interface UsePresaleCodeOptions {
|
|
14
|
+
/**
|
|
15
|
+
* The event as a route loader already fetched it (uncoded). Seeds the read so
|
|
16
|
+
* a server-rendered page paints without a spinner, exactly as
|
|
17
|
+
* `useEvent`'s own `initialData` does.
|
|
18
|
+
*/
|
|
19
|
+
initialEvent?: IEvent;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PresaleCodeField {
|
|
23
|
+
/**
|
|
24
|
+
* The event AS THIS BUYER MAY SEE IT — refetched with the applied code, so
|
|
25
|
+
* `tickets` already contains whatever that code unlocked.
|
|
26
|
+
*
|
|
27
|
+
* Read this rather than calling `useEvent` separately; both resolve to the
|
|
28
|
+
* same React Query entry, but only this one is guaranteed to be the coded
|
|
29
|
+
* read.
|
|
30
|
+
*/
|
|
31
|
+
event?: IEvent;
|
|
32
|
+
isLoading: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Render a code box at all?
|
|
35
|
+
*
|
|
36
|
+
* FALSE for every event with no presale — which is nearly all of them, and
|
|
37
|
+
* the reason this is a server fact rather than "always show it". A box on an
|
|
38
|
+
* event with no coded tier sends the buyer hunting for a code that does not
|
|
39
|
+
* exist.
|
|
40
|
+
*/
|
|
41
|
+
visible: boolean;
|
|
42
|
+
/** The text in the input — the applied code until the buyer edits it. */
|
|
43
|
+
input: string;
|
|
44
|
+
setInput: (value: string) => void;
|
|
45
|
+
/**
|
|
46
|
+
* The code currently APPLIED: what the event above was fetched with, and what
|
|
47
|
+
* the order must carry. `null` until one is submitted.
|
|
48
|
+
*/
|
|
49
|
+
code: string | null;
|
|
50
|
+
status: PresaleCodeStatus;
|
|
51
|
+
/**
|
|
52
|
+
* The message to put under the field, or `null` for none.
|
|
53
|
+
*
|
|
54
|
+
* A refusal names no tier and confirms no guess: the buyer learns their code
|
|
55
|
+
* is not one for this event and nothing else, or the box becomes a way to
|
|
56
|
+
* enumerate codes.
|
|
57
|
+
*/
|
|
58
|
+
message: string | null;
|
|
59
|
+
/** Apply what is in the input. No-op on an empty field. */
|
|
60
|
+
submit: () => void;
|
|
61
|
+
/** Drop the applied code — re-hides whatever it revealed. */
|
|
62
|
+
clear: () => void;
|
|
63
|
+
/** Whether the applied code opened something. */
|
|
64
|
+
isUnlocked: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The presale-code entry on an event page (Events 1.2).
|
|
69
|
+
*
|
|
70
|
+
* ## The gap this closes
|
|
71
|
+
*
|
|
72
|
+
* 1.2 shipped hidden tiers, an access-code column, SQL that reveals a tier to
|
|
73
|
+
* the right code, and a sell guard that enforces it at checkout — and no
|
|
74
|
+
* storefront on either rendering stack had an input. An artist could author a
|
|
75
|
+
* presale and mail the code to their list, and the recipient had nowhere to put
|
|
76
|
+
* it. The feature was unreachable by the only person it is for.
|
|
77
|
+
*
|
|
78
|
+
* ## Two facts, both of which have to come from the server
|
|
79
|
+
*
|
|
80
|
+
* `presale.hasCodedTiers` decides whether the box exists. A client cannot work
|
|
81
|
+
* it out: a hidden tier is filtered out of the response, so a presale-only
|
|
82
|
+
* event is indistinguishable from an event with no tickets yet.
|
|
83
|
+
*
|
|
84
|
+
* `presale.codeAccepted` decides what the box SAYS. Diffing the tier list
|
|
85
|
+
* before and after would look like it works and then fail on the case that
|
|
86
|
+
* matters — a tier that is VISIBLE but code-gated is already listed, so the
|
|
87
|
+
* correct code changes nothing on screen and the diff would call it wrong.
|
|
88
|
+
*
|
|
89
|
+
* ## Persistence
|
|
90
|
+
*
|
|
91
|
+
* The applied code is stored per event in `sessionStorage` (see
|
|
92
|
+
* `utils/presaleCode`), so it survives the reload, the payment redirect and the
|
|
93
|
+
* back button, and so the page and the ticket modal — separate components each
|
|
94
|
+
* with their own `useEvent` — always agree on it. Without that, a buyer unlocks
|
|
95
|
+
* a tier, walks to checkout, and the order is refused for want of the code they
|
|
96
|
+
* already typed. `?accessCode=` on the URL seeds it once, which is what makes a
|
|
97
|
+
* mailed presale link work on arrival.
|
|
98
|
+
*
|
|
99
|
+
* ```tsx
|
|
100
|
+
* const presale = usePresaleCode(slug);
|
|
101
|
+
* // presale.event already has the unlocked tiers in it
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export function usePresaleCode(eventKey?: string, options: UsePresaleCodeOptions = {}): PresaleCodeField {
|
|
105
|
+
const key = eventKey ?? "";
|
|
106
|
+
|
|
107
|
+
const code = useSyncExternalStore(
|
|
108
|
+
subscribePresaleCode,
|
|
109
|
+
() => readPresaleCode(key),
|
|
110
|
+
// Server render: no storage, so nothing is applied. The first client render
|
|
111
|
+
// picks up a stored code and refetches with it.
|
|
112
|
+
() => null,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
/** `null` = untouched, so the field shows the applied code until it is edited. */
|
|
116
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
117
|
+
|
|
118
|
+
// A mailed presale link lands with the code already on it. Seeded once, and
|
|
119
|
+
// never over an applied code — a buyer who cleared one did so on purpose.
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!key || readPresaleCode(key)) return;
|
|
122
|
+
const fromUrl = readPresaleCodeFromUrl();
|
|
123
|
+
if (fromUrl) writePresaleCode(key, fromUrl);
|
|
124
|
+
}, [key]);
|
|
125
|
+
|
|
126
|
+
const { data: event, isLoading, isFetching } = useEvent(eventKey, {
|
|
127
|
+
accessCode: code,
|
|
128
|
+
initialData: options.initialEvent,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const presale = event?.presale;
|
|
132
|
+
|
|
133
|
+
// While a newly applied code is in flight the event on hand is still the
|
|
134
|
+
// PREVIOUS response (kept deliberately, so the page does not blank), and its
|
|
135
|
+
// verdict describes the old code. Reporting it would flash "not valid" at a
|
|
136
|
+
// buyer whose code is about to be accepted.
|
|
137
|
+
//
|
|
138
|
+
// `!presale` is an API that does not report presale state at all (an older
|
|
139
|
+
// deployment, or the list endpoint's shape). Silence, not a spinner that
|
|
140
|
+
// never resolves.
|
|
141
|
+
const status: PresaleCodeStatus = !code || !presale
|
|
142
|
+
? "idle"
|
|
143
|
+
: isFetching || presale.codeAccepted === null
|
|
144
|
+
? "checking"
|
|
145
|
+
: presale.codeAccepted
|
|
146
|
+
? "accepted"
|
|
147
|
+
: "rejected";
|
|
148
|
+
|
|
149
|
+
const submit = useCallback(() => {
|
|
150
|
+
const trimmed = (draft ?? "").trim();
|
|
151
|
+
if (!trimmed || !key) return;
|
|
152
|
+
writePresaleCode(key, trimmed);
|
|
153
|
+
}, [draft, key]);
|
|
154
|
+
|
|
155
|
+
const clear = useCallback(() => {
|
|
156
|
+
if (!key) return;
|
|
157
|
+
setDraft("");
|
|
158
|
+
writePresaleCode(key, null);
|
|
159
|
+
}, [key]);
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
event,
|
|
163
|
+
isLoading,
|
|
164
|
+
visible: !!presale?.hasCodedTiers,
|
|
165
|
+
input: draft ?? code ?? "",
|
|
166
|
+
setInput: setDraft,
|
|
167
|
+
code,
|
|
168
|
+
status,
|
|
169
|
+
message:
|
|
170
|
+
status === "accepted"
|
|
171
|
+
? "Code applied."
|
|
172
|
+
: // Says only that the code is not one for this event. Naming a tier, or
|
|
173
|
+
// hinting that one exists, would turn the box into a way to guess codes.
|
|
174
|
+
status === "rejected"
|
|
175
|
+
? "That code isn't valid for this event."
|
|
176
|
+
: null,
|
|
177
|
+
submit,
|
|
178
|
+
clear,
|
|
179
|
+
isUnlocked: status === "accepted",
|
|
180
|
+
};
|
|
181
|
+
}
|
package/src/ui/headless/index.ts
CHANGED
|
@@ -4,6 +4,11 @@ export * from "./dialog";
|
|
|
4
4
|
export * from "./donation";
|
|
5
5
|
export * from "./offer";
|
|
6
6
|
export { MembershipGate, type MembershipGateProps, type MembershipGateState } from "./membership/MembershipGate";
|
|
7
|
+
// S.4 members-only gates: the notice a storefront draws BEFORE the buyer pays.
|
|
8
|
+
export {
|
|
9
|
+
useMembershipGateNotice,
|
|
10
|
+
type UseMembershipGateNoticeInput,
|
|
11
|
+
} from "./membership/useMembershipGateNotice";
|
|
7
12
|
export { useEmailListForm, type UseEmailListFormOptions, type FormStatus } from "./forms/useEmailListForm";
|
|
8
13
|
export { useContactForm } from "./forms/useContactForm";
|
|
9
14
|
export { useSectionedForm } from "./forms/useSectionedForm";
|
|
@@ -17,12 +22,32 @@ export {
|
|
|
17
22
|
type CheckoutShippingData,
|
|
18
23
|
type SelectedShippingRate,
|
|
19
24
|
} from "./checkout/useCheckout";
|
|
25
|
+
// Inventory holds — the reservation a buyer is given while they pay, and the
|
|
26
|
+
// 409 they get when it lapses. Shared by both rendering stacks.
|
|
27
|
+
export {
|
|
28
|
+
useInventoryHold,
|
|
29
|
+
type InventoryHoldState,
|
|
30
|
+
type UseInventoryHoldOptions,
|
|
31
|
+
} from "./checkout/useInventoryHold";
|
|
32
|
+
export {
|
|
33
|
+
isHoldExpiredError,
|
|
34
|
+
holdExpiredMessage,
|
|
35
|
+
holdMsRemaining,
|
|
36
|
+
formatHoldRemaining,
|
|
37
|
+
HOLD_EXPIRED_CODE,
|
|
38
|
+
} from "./checkout/inventoryHold";
|
|
20
39
|
export { useLoginFlow, type LoginStep } from "./auth/useLoginFlow";
|
|
21
40
|
export { useSignupForm, type UseSignupFormOptions } from "./auth/useSignupForm";
|
|
22
41
|
|
|
23
42
|
// Flow primitives (Part A2) — multi-step purchase/booking/subscribe/chat flows,
|
|
24
43
|
// each composed from the data hooks so a creator can rebuild any page.
|
|
25
44
|
export { useEventCheckout, type UseEventCheckoutOptions, type EventCheckoutStep } from "./event/useEventCheckout";
|
|
45
|
+
export {
|
|
46
|
+
usePresaleCode,
|
|
47
|
+
type PresaleCodeField,
|
|
48
|
+
type PresaleCodeStatus,
|
|
49
|
+
type UsePresaleCodeOptions,
|
|
50
|
+
} from "./event/usePresaleCode";
|
|
26
51
|
export {
|
|
27
52
|
useCouponField,
|
|
28
53
|
apiErrorMessage,
|
|
@@ -11,8 +11,9 @@ export interface UseInvoicePaymentOptions {
|
|
|
11
11
|
/**
|
|
12
12
|
* Headless invoice payment: loads the invoice, starts its payment, and exposes
|
|
13
13
|
* the PDF download. Composes `useInvoice`, `useDownloadInvoicePdf`,
|
|
14
|
-
* `usePaymentFlow`. Stripe UI renders from `clientSecret`;
|
|
15
|
-
*
|
|
14
|
+
* `usePaymentFlow`. Stripe UI renders from `clientSecret`; Paystack opens its
|
|
15
|
+
* inline modal (and `openPaystackCheckout` re-opens it after a dismissal).
|
|
16
|
+
* Finalize on the return page with `useInvoiceFinalize`.
|
|
16
17
|
*/
|
|
17
18
|
export function useInvoicePayment(invoiceId?: string, opts: UseInvoicePaymentOptions = {}) {
|
|
18
19
|
const invoice = useInvoice(invoiceId);
|
|
@@ -35,6 +36,10 @@ export function useInvoicePayment(invoiceId?: string, opts: UseInvoicePaymentOpt
|
|
|
35
36
|
clientSecret: flow.clientSecret,
|
|
36
37
|
paymentId: flow.paymentId,
|
|
37
38
|
provider: flow.provider,
|
|
39
|
+
/** Paystack: the started inline-checkout session and the way back into it. */
|
|
40
|
+
isPaystack: flow.isPaystack,
|
|
41
|
+
canOpenPaystack: flow.canOpenPaystack,
|
|
42
|
+
openPaystackCheckout: flow.openPaystackCheckout,
|
|
38
43
|
/** Sales-tax quote (built from the invoice's own tax columns — no re-quote). */
|
|
39
44
|
taxQuote: flow.result?.taxQuote ?? null,
|
|
40
45
|
isStarting: flow.isStarting,
|
|
@@ -8,6 +8,12 @@ import {
|
|
|
8
8
|
} from "../../../data/queries/useSubscriptions";
|
|
9
9
|
import { readAttributionRef } from "../../../utils/attribution";
|
|
10
10
|
import { readLanding } from "../../../utils/landing";
|
|
11
|
+
import {
|
|
12
|
+
hasPaystackSession,
|
|
13
|
+
openPaystackCheckout as openPaystackModal,
|
|
14
|
+
type PaystackCheckoutOutcome,
|
|
15
|
+
type PaystackCheckoutSession,
|
|
16
|
+
} from "../../../utils/paystackCheckout";
|
|
11
17
|
|
|
12
18
|
export type MembershipCheckoutStep = "select" | "payment";
|
|
13
19
|
export type BillingCycle = "month" | "year";
|
|
@@ -15,7 +21,7 @@ export type BillingCycle = "month" | "year";
|
|
|
15
21
|
export interface UseMembershipCheckoutOptions {
|
|
16
22
|
/** Pre-select a tier (e.g. from a `?membershipTierId=` param). */
|
|
17
23
|
initialTierId?: string;
|
|
18
|
-
/** Where
|
|
24
|
+
/** Where the provider returns the member after a paid subscription. Default `/i/account?tab=membership&confirmSubscription=true`. */
|
|
19
25
|
returnPath?: string;
|
|
20
26
|
/** Where free activations land. Default `/i/account?tab=membership`. */
|
|
21
27
|
freeReturnPath?: string;
|
|
@@ -35,6 +41,11 @@ const tierIsFree = (tier: { payWhatYouWant: boolean; priceMonthly?: number; pric
|
|
|
35
41
|
* subscription. Composes `useGetMembershipTiers`, `useCreateFreeSubscription`,
|
|
36
42
|
* `useCreateSubscription`, and (for the return page) `useConfirmLatestSubscription`.
|
|
37
43
|
* Paid flow exposes `clientSecret` for `ForgePaymentProvider`.
|
|
44
|
+
*
|
|
45
|
+
* Three outcomes, not two: a member who ALREADY subscribes to this artist is
|
|
46
|
+
* changing tier, which the server settles by proration on their existing
|
|
47
|
+
* subscription. That completes server-side with no payment step — it is
|
|
48
|
+
* finished the moment `subscribe()` resolves, exactly like a free activation.
|
|
38
49
|
*/
|
|
39
50
|
export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
40
51
|
const { user } = usePublicAuth();
|
|
@@ -48,6 +59,8 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
48
59
|
const [billingCycle, setBillingCycle] = useState<BillingCycle>("month");
|
|
49
60
|
const [customAmount, setCustomAmount] = useState<number>(0);
|
|
50
61
|
const [clientSecret, setClientSecret] = useState<string | undefined>();
|
|
62
|
+
/** The started Paystack checkout, kept so the modal can be re-opened. */
|
|
63
|
+
const [paystackSession, setPaystackSession] = useState<PaystackCheckoutSession | null>(null);
|
|
51
64
|
const [error, setError] = useState<string | null>(null);
|
|
52
65
|
|
|
53
66
|
const selectedTier = useMemo(
|
|
@@ -72,6 +85,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
72
85
|
return;
|
|
73
86
|
}
|
|
74
87
|
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
88
|
+
const returnUrl = `${origin}${opts.returnPath ?? "/i/account?tab=membership&confirmSubscription=true"}`;
|
|
75
89
|
const attributionRefId = readAttributionRef() ?? undefined;
|
|
76
90
|
const landing = readLanding() ?? {};
|
|
77
91
|
try {
|
|
@@ -87,10 +101,46 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
87
101
|
amount,
|
|
88
102
|
billingCycle,
|
|
89
103
|
membershipTierId: selectedTier.id,
|
|
90
|
-
returnUrl
|
|
104
|
+
returnUrl,
|
|
91
105
|
attributionRefId,
|
|
92
106
|
...landing,
|
|
93
107
|
});
|
|
108
|
+
|
|
109
|
+
// A member who already subscribes is CHANGING TIER: the server moved their
|
|
110
|
+
// existing subscription onto the new price with proration, so there is
|
|
111
|
+
// nothing to pay and no card to collect. Sending them to a payment step
|
|
112
|
+
// with no client secret is a form that can never be completed.
|
|
113
|
+
// Every provider session counts, not just Stripe's secret. Reading a
|
|
114
|
+
// missing `clientSecret` as "nothing to pay" would activate a Paystack
|
|
115
|
+
// membership nobody has paid for.
|
|
116
|
+
const requiresPayment = data.requiresPayment ?? !!(data.clientSecret || data.accessCode || data.checkoutUrl);
|
|
117
|
+
if (!requiresPayment) {
|
|
118
|
+
if (opts.onComplete) opts.onComplete();
|
|
119
|
+
else if (typeof window !== "undefined") {
|
|
120
|
+
window.location.href = `${origin}${opts.freeReturnPath ?? "/i/account?tab=membership"}`;
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (data.accessCode || data.checkoutUrl) {
|
|
126
|
+
// Paystack: the fan pays in a modal over this page. The helper navigates
|
|
127
|
+
// to `returnUrl` on success, the same return leg the hosted redirect
|
|
128
|
+
// used, so `confirmLatest` still reconciles on arrival.
|
|
129
|
+
const session: PaystackCheckoutSession = {
|
|
130
|
+
accessCode: data.accessCode,
|
|
131
|
+
checkoutUrl: data.checkoutUrl,
|
|
132
|
+
returnUrl,
|
|
133
|
+
};
|
|
134
|
+
setPaystackSession(session);
|
|
135
|
+
setStep("payment");
|
|
136
|
+
void openPaystackModal(session, {
|
|
137
|
+
// Closing the popup leaves the subscription unpaid and re-openable.
|
|
138
|
+
onDismiss: () => {},
|
|
139
|
+
onError: (message) => setError(message ?? "The payment could not be completed. Please try again."),
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
94
144
|
setClientSecret(data.clientSecret);
|
|
95
145
|
setStep("payment");
|
|
96
146
|
} catch (e) {
|
|
@@ -113,6 +163,17 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
113
163
|
amount,
|
|
114
164
|
subscribe,
|
|
115
165
|
clientSecret,
|
|
166
|
+
/** True once the subscription started on Paystack rather than Stripe. */
|
|
167
|
+
isPaystack: !!paystackSession,
|
|
168
|
+
canOpenPaystack: hasPaystackSession(paystackSession),
|
|
169
|
+
/** Re-open the Paystack modal on the same subscription charge. */
|
|
170
|
+
openPaystackCheckout: (): Promise<PaystackCheckoutOutcome> =>
|
|
171
|
+
paystackSession
|
|
172
|
+
? openPaystackModal(paystackSession, {
|
|
173
|
+
onDismiss: () => {},
|
|
174
|
+
onError: (message) => setError(message ?? "The payment could not be completed. Please try again."),
|
|
175
|
+
})
|
|
176
|
+
: Promise.resolve("unavailable" as PaystackCheckoutOutcome),
|
|
116
177
|
isProcessing: createPaid.isPending || createFree.isPending,
|
|
117
178
|
error,
|
|
118
179
|
/** Call on the return page to reconcile the latest subscription. */
|