@tribe-nest/forge 3.20.0 → 3.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
- package/src/data/queries/useAccountSettings.ts +15 -2
- package/src/data/queries/useAuthActions.ts +51 -7
- package/src/data/queries/useEvents.ts +66 -4
- package/src/data/queries/useMembership.ts +8 -2
- package/src/data/queries/useMyBookings.ts +12 -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/server/index.ts +52 -0
- package/src/types/models.ts +151 -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/attendees.ts +187 -0
- package/src/ui/format/membershipGate.ts +209 -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/inventoryHold.ts +83 -0
- package/src/ui/headless/checkout/useCheckout.ts +72 -0
- package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
- package/src/ui/headless/event/useEventCheckout.ts +133 -2
- package/src/ui/headless/event/usePresaleCode.ts +181 -0
- package/src/ui/headless/index.ts +25 -0
- package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
- package/src/ui/headless/offer/OfferContext.tsx +55 -0
- package/src/ui/index.ts +42 -0
- package/src/ui/styled/AccountDashboard.tsx +70 -8
- package/src/ui/styled/AddToCalendar.tsx +34 -10
- package/src/ui/styled/Checkout.tsx +18 -1
- package/src/ui/styled/CoachingConfirmation.tsx +4 -0
- package/src/ui/styled/CourseDetail.tsx +30 -1
- package/src/ui/styled/EventConfirmation.tsx +2 -0
- package/src/ui/styled/EventDetail.tsx +53 -22
- package/src/ui/styled/EventTickets.tsx +156 -5
- package/src/ui/styled/HoldNotice.tsx +192 -0
- package/src/ui/styled/MembershipGateNotice.tsx +159 -0
- package/src/ui/styled/OfferButton.tsx +23 -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/TicketTransfer.tsx +69 -40
- 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/utils/_tests/presaleCode.spec.ts +168 -0
- package/src/utils/_tests/structuredData.spec.ts +275 -0
- package/src/utils/presaleCode.ts +96 -0
- package/src/utils/structuredData.ts +361 -27
|
@@ -1,11 +1,24 @@
|
|
|
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 {
|
|
9
|
+
buildAttendeeSlots,
|
|
10
|
+
setAttendeeName,
|
|
11
|
+
validateAttendeeNames,
|
|
12
|
+
attendeesPayload,
|
|
13
|
+
collectsAttendees,
|
|
14
|
+
buyerFullName,
|
|
15
|
+
type AttendeeNames,
|
|
16
|
+
} from "../../format/attendees";
|
|
17
|
+
import { holdExpiredMessage, isHoldExpiredError } from "../checkout/inventoryHold";
|
|
18
|
+
import { useInventoryHold } from "../checkout/useInventoryHold";
|
|
7
19
|
import { computeBookingFeeAmount } from "../../format/bookingFee";
|
|
8
20
|
import { isPayWhatYouWant, pwywDefaultAmount, resolveUnitPrice, ticketSubtotals } from "../../format/pwyw";
|
|
21
|
+
import { parseMembershipGateError, type MembershipGateRefusal } from "../../format/membershipGate";
|
|
9
22
|
import { readAttributionRef } from "../../../utils/attribution";
|
|
10
23
|
import { readLanding } from "../../../utils/landing";
|
|
11
24
|
|
|
@@ -36,7 +49,13 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
36
49
|
const { setTickets, hasTicketsFor } = useCart();
|
|
37
50
|
// The detail is resolved by slug; the order is created against the real event
|
|
38
51
|
// id (the orders endpoint looks the event up by id, not slug).
|
|
39
|
-
|
|
52
|
+
// Read THROUGH the presale field rather than calling `useEvent` directly: it
|
|
53
|
+
// is the same React Query entry, but it is the one fetched with whatever
|
|
54
|
+
// presale code this buyer has applied, so `event.tickets` already contains the
|
|
55
|
+
// tiers that code unlocked. Calling `useEvent(slug)` here instead would give a
|
|
56
|
+
// tier list that silently omits what the buyer just unlocked access to.
|
|
57
|
+
const presale = usePresaleCode(slug);
|
|
58
|
+
const { event, isLoading } = presale;
|
|
40
59
|
const eventId = event?.id;
|
|
41
60
|
const createOrder = useCreateEventOrder(eventId);
|
|
42
61
|
const flow = usePaymentFlow({ path: `/public/events/${eventId}/start-payment`, autoStart: false });
|
|
@@ -53,8 +72,31 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
53
72
|
const [lastName, setLastName] = useState(user?.lastName ?? "");
|
|
54
73
|
const [email, setEmail] = useState(user?.email ?? "");
|
|
55
74
|
const [questionnaire, setQuestionnaire] = useState<unknown>(undefined);
|
|
75
|
+
/**
|
|
76
|
+
* ticketId → per-seat names, positional.
|
|
77
|
+
*
|
|
78
|
+
* `collectAttendeeDetails` is an event setting an artist turns on for a
|
|
79
|
+
* guest-list show. It shipped with an admin toggle, a backend that accepts a
|
|
80
|
+
* per-ticket attendees map, and NO storefront asking for anything — so every
|
|
81
|
+
* pass printed the buyer's name and the door list was wrong, with nothing
|
|
82
|
+
* explaining why the setting did nothing. Both stacks render this component,
|
|
83
|
+
* so collecting it here is what makes the toggle real.
|
|
84
|
+
*/
|
|
85
|
+
const [attendeeNames, setAttendeeNames] = useState<AttendeeNames>({});
|
|
56
86
|
const [returnUrl, setReturnUrl] = useState("");
|
|
57
87
|
const [error, setError] = useState<string | null>(null);
|
|
88
|
+
/**
|
|
89
|
+
* A `MEMBERSHIP_GATE` refusal, STRUCTURED — which tier, and whether the buyer
|
|
90
|
+
* needs to sign in or to join.
|
|
91
|
+
*
|
|
92
|
+
* The gate should have been visible on the tier long before this (the read
|
|
93
|
+
* announces it), so reaching here means either the buyer's membership lapsed
|
|
94
|
+
* mid-checkout or the page rendered from a stale cache. Either way the useful
|
|
95
|
+
* response is the badge's: name the tier and offer the way in. Kept as state
|
|
96
|
+
* rather than a message because a sentence alone turns a silent failure into a
|
|
97
|
+
* visible dead end.
|
|
98
|
+
*/
|
|
99
|
+
const [gateRefusal, setGateRefusal] = useState<MembershipGateRefusal | null>(null);
|
|
58
100
|
/**
|
|
59
101
|
* The booking fee the SERVER charged, once an order exists.
|
|
60
102
|
*
|
|
@@ -232,6 +274,7 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
232
274
|
|
|
233
275
|
const continueToPayment = async () => {
|
|
234
276
|
setError(null);
|
|
277
|
+
setGateRefusal(null);
|
|
235
278
|
if (!slug || !eventId) return;
|
|
236
279
|
if (!firstName.trim() || !lastName.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
237
280
|
setError("Please enter your name and a valid email.");
|
|
@@ -247,7 +290,17 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
247
290
|
firstName,
|
|
248
291
|
lastName,
|
|
249
292
|
questionnaire,
|
|
293
|
+
// Omitted entirely when the event does not collect them, so an ordinary
|
|
294
|
+
// purchase posts the body it always did.
|
|
295
|
+
...(attendeesPayload(attendeeSlots) ? { attendees: attendeesPayload(attendeeSlots) } : {}),
|
|
250
296
|
couponCode: coupon.submittedCode,
|
|
297
|
+
// The presale code, not a discount. The sell guard re-checks it on the
|
|
298
|
+
// ORDER, so it has to travel with the purchase and not just with the
|
|
299
|
+
// read that revealed the tier — otherwise a buyer unlocks a presale,
|
|
300
|
+
// fills in their details, and is refused at the card step for want of
|
|
301
|
+
// the code they already typed. Omitted when there is none, so an
|
|
302
|
+
// ordinary purchase posts the body it always did.
|
|
303
|
+
...(presale.code ? { accessCode: presale.code } : {}),
|
|
251
304
|
attributionRefId: readAttributionRef() ?? undefined,
|
|
252
305
|
...(readLanding() ?? {}),
|
|
253
306
|
});
|
|
@@ -279,10 +332,36 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
279
332
|
if (result.provider && result.provider !== "stripe") return; // Paystack redirected
|
|
280
333
|
setStep("payment");
|
|
281
334
|
} catch (e) {
|
|
335
|
+
/**
|
|
336
|
+
* A members-only tier the buyer cannot have. Checked BEFORE the coupon
|
|
337
|
+
* branch: a buyer who happened to type a discount code would otherwise be
|
|
338
|
+
* told the CODE was refused and sent to fix a code that was never the
|
|
339
|
+
* problem.
|
|
340
|
+
*/
|
|
341
|
+
const gate = parseMembershipGateError(e);
|
|
342
|
+
if (gate) {
|
|
343
|
+
setGateRefusal(gate);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
282
346
|
// A refused code fails the whole request, so with one entered the message
|
|
283
347
|
// belongs next to the field the buyer just used. The message is the API's
|
|
284
348
|
// own — "This coupon has expired", "…does not reach this coupon's minimum
|
|
285
349
|
// spend" — never a house "invalid code" that hides which it was.
|
|
350
|
+
/**
|
|
351
|
+
* The reservation lapsed mid-payment (409 `INVENTORY_HOLD_EXPIRED`) —
|
|
352
|
+
* NOT sold out, and emphatically not a bad coupon. Checked before the
|
|
353
|
+
* coupon branch for the same reason the gate is: a buyer who happened to
|
|
354
|
+
* type a discount code would otherwise be sent to fix a code that was
|
|
355
|
+
* never the problem, while the one action that works — retry — is not
|
|
356
|
+
* offered at all.
|
|
357
|
+
*
|
|
358
|
+
* Event ticket holds are taken UNCONDITIONALLY (no feature switch), so
|
|
359
|
+
* unlike the product cart this path is live for every profile.
|
|
360
|
+
*/
|
|
361
|
+
if (isHoldExpiredError(e)) {
|
|
362
|
+
setError(holdExpiredMessage(e, "Your ticket reservation expired before payment finished. Please try again."));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
286
365
|
if (coupon.submittedCode) coupon.fail(e);
|
|
287
366
|
else setError(errMessage(e));
|
|
288
367
|
}
|
|
@@ -306,9 +385,47 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
306
385
|
setStep("details");
|
|
307
386
|
};
|
|
308
387
|
|
|
388
|
+
/**
|
|
389
|
+
* One slot per seat, in cart order, with the buyer pre-filled into the first.
|
|
390
|
+
* Empty when the event does not ask — so a normal event renders and posts
|
|
391
|
+
* exactly what it always did.
|
|
392
|
+
*/
|
|
393
|
+
/**
|
|
394
|
+
* The reservation clock. `undefined` on an older API and `null` when no hold
|
|
395
|
+
* was taken or the order is already paid — both render nothing, i.e. exactly
|
|
396
|
+
* the behaviour before holds existed.
|
|
397
|
+
*/
|
|
398
|
+
const holdExpiresAt = (flow.result as { holdExpiresAt?: string | null } | undefined)?.holdExpiresAt ?? null;
|
|
399
|
+
const hold = useInventoryHold(holdExpiresAt);
|
|
400
|
+
|
|
401
|
+
const attendeeSlots = useMemo(
|
|
402
|
+
() =>
|
|
403
|
+
collectsAttendees(event)
|
|
404
|
+
? buildAttendeeSlots({
|
|
405
|
+
tickets: (event?.tickets ?? []).filter((t) => (selectedTickets[t.id] ?? 0) > 0),
|
|
406
|
+
quantities: selectedTickets,
|
|
407
|
+
names: attendeeNames,
|
|
408
|
+
buyerName: buyerFullName({ firstName, lastName }),
|
|
409
|
+
})
|
|
410
|
+
: [],
|
|
411
|
+
[event, selectedTickets, attendeeNames, firstName, lastName],
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
const attendeeCheck = useMemo(() => validateAttendeeNames(attendeeSlots), [attendeeSlots]);
|
|
415
|
+
|
|
416
|
+
const setAttendee = (ticketId: string, index: number, value: string) =>
|
|
417
|
+
setAttendeeNames((prev) => setAttendeeName(prev, ticketId, index, value));
|
|
418
|
+
|
|
309
419
|
return {
|
|
310
420
|
event,
|
|
311
421
|
isLoading,
|
|
422
|
+
/** Countdown on the ticket reservation. `active:false` when there is none. */
|
|
423
|
+
hold,
|
|
424
|
+
/** Per-seat name inputs. Empty array when the event does not collect them. */
|
|
425
|
+
attendeeSlots,
|
|
426
|
+
setAttendee,
|
|
427
|
+
/** `{ok:false}` while a required seat name is missing or too long. */
|
|
428
|
+
attendeeCheck,
|
|
312
429
|
step,
|
|
313
430
|
setStep,
|
|
314
431
|
selectedTickets,
|
|
@@ -354,6 +471,12 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
354
471
|
/** Discount code state + the server's quote. See `useCouponField`. */
|
|
355
472
|
coupon,
|
|
356
473
|
clearCoupon,
|
|
474
|
+
/**
|
|
475
|
+
* Presale-code state (1.2) — a DIFFERENT code from `coupon`: it buys access
|
|
476
|
+
* to a tier, it does not change a price. `presale.visible` is false on every
|
|
477
|
+
* event without a coded tier, so a surface can render it unconditionally.
|
|
478
|
+
*/
|
|
479
|
+
presale,
|
|
357
480
|
/** Authoritative sales-tax quote from start-payment (display only). */
|
|
358
481
|
taxQuote: flow.result?.taxQuote ?? null,
|
|
359
482
|
firstName,
|
|
@@ -373,5 +496,13 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
373
496
|
returnUrl,
|
|
374
497
|
isProcessing: createOrder.isPending || flow.isStarting,
|
|
375
498
|
error,
|
|
499
|
+
/**
|
|
500
|
+
* A `MEMBERSHIP_GATE` refusal, structured — pass it to
|
|
501
|
+
* `<MembershipGateNotice refusal={…}>` (or `useMembershipGateNotice`) rather
|
|
502
|
+
* than printing `error`, which is deliberately NOT set for this case: the
|
|
503
|
+
* buyer needs the tier's name and a way in, and a generic red line gives
|
|
504
|
+
* them neither.
|
|
505
|
+
*/
|
|
506
|
+
gateRefusal,
|
|
376
507
|
};
|
|
377
508
|
}
|
|
@@ -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,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { PublicMembershipGate } from "../../../types/models";
|
|
3
|
+
import { useGetMembershipTiers } from "../../../data/queries/useMembership";
|
|
4
|
+
import {
|
|
5
|
+
buildMembershipGateNotice,
|
|
6
|
+
isMembershipGateLocked,
|
|
7
|
+
type MembershipGateNotice,
|
|
8
|
+
type MembershipGateRefusal,
|
|
9
|
+
} from "../../format/membershipGate";
|
|
10
|
+
|
|
11
|
+
export interface UseMembershipGateNoticeInput {
|
|
12
|
+
/** The gate announced by the read (`ticket.membershipGate`, `product.membershipGate`, …). */
|
|
13
|
+
gate?: PublicMembershipGate | null;
|
|
14
|
+
/** A `MEMBERSHIP_GATE` refusal from a failed purchase. Wins over `gate`. */
|
|
15
|
+
refusal?: MembershipGateRefusal | null;
|
|
16
|
+
/** What the thing is, in the buyer's words — "This ticket", "This course". */
|
|
17
|
+
itemLabel?: string;
|
|
18
|
+
/** Where a signed-out visitor signs in. Code sites mount theirs under `/i`. */
|
|
19
|
+
loginPath?: string;
|
|
20
|
+
/** Where a signed-in non-member goes to join — the tier listing. */
|
|
21
|
+
membershipPath?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The members-only notice for one gated thing, tier names and all.
|
|
26
|
+
*
|
|
27
|
+
* ## Why the names are fetched here and not sent by the API
|
|
28
|
+
*
|
|
29
|
+
* A gate comes down as `requiredTierIds`, and "you need tier 8f3c-…" is not a
|
|
30
|
+
* sentence anyone can act on. The public tier list is already loaded by every
|
|
31
|
+
* storefront that sells memberships, is cached by React Query under one key per
|
|
32
|
+
* profile, and — unlike an id embedded in a ticket payload — stays correct when
|
|
33
|
+
* the artist renames a tier. So the ids are resolved against it here, once, for
|
|
34
|
+
* every surface.
|
|
35
|
+
*
|
|
36
|
+
* The names are best-effort by design: the query can be in flight, and a gating
|
|
37
|
+
* tier can be one the public list does not carry (an archived or unlisted tier
|
|
38
|
+
* still gates). `buildMembershipGateNotice` therefore degrades to "is for
|
|
39
|
+
* members" rather than showing an id or rendering nothing — a badge with slightly
|
|
40
|
+
* vaguer wording is a working funnel; a blank space is the bug this closes.
|
|
41
|
+
*
|
|
42
|
+
* Returns `null` when nothing is gated, which is also what every surface sees
|
|
43
|
+
* while the gates feature switch is off.
|
|
44
|
+
*/
|
|
45
|
+
export function useMembershipGateNotice(input: UseMembershipGateNoticeInput): MembershipGateNotice | null {
|
|
46
|
+
const locked = isMembershipGateLocked(input.gate) || !!input.refusal;
|
|
47
|
+
// Only pulled once something is actually locked — an ungated storefront must
|
|
48
|
+
// not acquire a request it never needed.
|
|
49
|
+
const { data: tiers } = useGetMembershipTiers({ enabled: locked });
|
|
50
|
+
|
|
51
|
+
const requiredTierIds = input.refusal?.requiredTierIds ?? input.gate?.requiredTierIds ?? [];
|
|
52
|
+
|
|
53
|
+
return useMemo(() => {
|
|
54
|
+
if (!locked) return null;
|
|
55
|
+
const byId = new Map((tiers ?? []).map((tier) => [tier.id, tier.name]));
|
|
56
|
+
return buildMembershipGateNotice({
|
|
57
|
+
gate: input.gate,
|
|
58
|
+
refusal: input.refusal,
|
|
59
|
+
tierNames: requiredTierIds.map((id) => byId.get(id) ?? "").filter(Boolean),
|
|
60
|
+
itemLabel: input.itemLabel,
|
|
61
|
+
loginPath: input.loginPath,
|
|
62
|
+
membershipPath: input.membershipPath,
|
|
63
|
+
currentPath: currentPath(),
|
|
64
|
+
});
|
|
65
|
+
// `requiredTierIds` is a fresh array each render; its CONTENTS are the input.
|
|
66
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
67
|
+
}, [
|
|
68
|
+
locked,
|
|
69
|
+
tiers,
|
|
70
|
+
requiredTierIds.join(","),
|
|
71
|
+
input.gate?.reason,
|
|
72
|
+
input.refusal?.reason,
|
|
73
|
+
input.itemLabel,
|
|
74
|
+
input.loginPath,
|
|
75
|
+
input.membershipPath,
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Where the buyer is standing, so signing in returns them to it. Empty during SSR. */
|
|
80
|
+
function currentPath(): string {
|
|
81
|
+
if (typeof window === "undefined") return "";
|
|
82
|
+
return `${window.location.pathname}${window.location.search}`;
|
|
83
|
+
}
|
|
@@ -3,6 +3,8 @@ import type { IPublicProduct, IPublicProductVariant } from "../../../types/model
|
|
|
3
3
|
import { useGetProduct } from "../../../data/queries/useProducts";
|
|
4
4
|
import { useCreateOrder } from "../../../data/queries/useOrders";
|
|
5
5
|
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
6
|
+
import { holdExpiredMessage, isHoldExpiredError } from "../checkout/inventoryHold";
|
|
7
|
+
import { useInventoryHold, type InventoryHoldState } from "../checkout/useInventoryHold";
|
|
6
8
|
|
|
7
9
|
export type OfferStep = "details" | "payment";
|
|
8
10
|
|
|
@@ -38,6 +40,13 @@ export interface OfferContextValue {
|
|
|
38
40
|
clientSecret?: string;
|
|
39
41
|
returnUrl: string;
|
|
40
42
|
continueToPayment: () => Promise<void>;
|
|
43
|
+
/** The stock reservation held while the buyer pays. All-null when none was taken. */
|
|
44
|
+
hold: InventoryHoldState;
|
|
45
|
+
/** The server's 409 `INVENTORY_HOLD_EXPIRED` message — never sold-out. */
|
|
46
|
+
holdExpiredError: string | null;
|
|
47
|
+
/** Re-take the reservation on the same order. */
|
|
48
|
+
retryHold: () => Promise<void>;
|
|
49
|
+
isRetryingHold: boolean;
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
const OfferContext = createContext<OfferContextValue | null>(null);
|
|
@@ -72,6 +81,10 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
|
|
|
72
81
|
const [confirmEmail, setConfirmEmail] = useState("");
|
|
73
82
|
const [error, setError] = useState<string | null>(null);
|
|
74
83
|
const [returnUrl, setReturnUrl] = useState("");
|
|
84
|
+
const [orderId, setOrderId] = useState<string | null>(null);
|
|
85
|
+
/** The window the ORDER reported, until start-payment restarts it. */
|
|
86
|
+
const [orderHoldExpiresAt, setOrderHoldExpiresAt] = useState<string | null>(null);
|
|
87
|
+
const [holdExpiredError, setHoldExpiredError] = useState<string | null>(null);
|
|
75
88
|
|
|
76
89
|
const selectedVariant = useMemo(
|
|
77
90
|
() => variants.find((v) => v.id === selectedVariantId) ?? variants[0],
|
|
@@ -82,6 +95,7 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
|
|
|
82
95
|
|
|
83
96
|
const continueToPayment = async () => {
|
|
84
97
|
setError(null);
|
|
98
|
+
setHoldExpiredError(null);
|
|
85
99
|
if (!product || !selectedVariant) return;
|
|
86
100
|
if (!firstName.trim() || !lastName.trim()) {
|
|
87
101
|
setError("Please enter your name.");
|
|
@@ -114,6 +128,10 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
|
|
|
114
128
|
});
|
|
115
129
|
const ru = `${typeof window !== "undefined" ? window.location.origin : ""}${finalisePath ?? "/checkout/finalise"}?orderId=${created.orderId}`;
|
|
116
130
|
setReturnUrl(ru);
|
|
131
|
+
setOrderId(created.orderId);
|
|
132
|
+
// Absent/null for a digital item, a free order, or a profile with holds
|
|
133
|
+
// switched off — every one of which must render no countdown at all.
|
|
134
|
+
setOrderHoldExpiresAt(created.holdExpiresAt ?? null);
|
|
117
135
|
// Free checkout — skip payment.
|
|
118
136
|
if (created.subTotal === 0 && typeof window !== "undefined") {
|
|
119
137
|
window.location.href = ru;
|
|
@@ -124,11 +142,44 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
|
|
|
124
142
|
if (result.provider && result.provider !== "stripe") return;
|
|
125
143
|
setStep("payment");
|
|
126
144
|
} catch (e) {
|
|
145
|
+
// A lapsed reservation gets its own state so the buyer is offered the
|
|
146
|
+
// retry that re-acquires the same unit, rather than a bare error that
|
|
147
|
+
// reads like the item is gone.
|
|
148
|
+
if (isHoldExpiredError(e)) {
|
|
149
|
+
setHoldExpiredError(
|
|
150
|
+
holdExpiredMessage(e, "Your reservation expired before payment finished. Please try again."),
|
|
151
|
+
);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
127
154
|
const message = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
128
155
|
setError(message || "Something went wrong.");
|
|
129
156
|
}
|
|
130
157
|
};
|
|
131
158
|
|
|
159
|
+
/** Take the same unit again on the same order. */
|
|
160
|
+
const retryHold = async () => {
|
|
161
|
+
if (!orderId || !returnUrl) return;
|
|
162
|
+
setHoldExpiredError(null);
|
|
163
|
+
try {
|
|
164
|
+
const result = await flow.start({ orderId, returnUrl });
|
|
165
|
+
if (result.provider && result.provider !== "stripe") return; // Paystack redirected
|
|
166
|
+
setStep("payment");
|
|
167
|
+
} catch (e) {
|
|
168
|
+
if (isHoldExpiredError(e)) {
|
|
169
|
+
setHoldExpiredError(
|
|
170
|
+
holdExpiredMessage(e, "Your reservation expired before payment finished. Please try again."),
|
|
171
|
+
);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const message = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
175
|
+
setError(message || "Something went wrong.");
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// start-payment restarts the clock and reports the newer instant, so it wins
|
|
180
|
+
// over the create-time one.
|
|
181
|
+
const hold = useInventoryHold(flow.result?.holdExpiresAt ?? orderHoldExpiresAt);
|
|
182
|
+
|
|
132
183
|
const value: OfferContextValue = {
|
|
133
184
|
product,
|
|
134
185
|
isLoading,
|
|
@@ -156,6 +207,10 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
|
|
|
156
207
|
clientSecret: flow.result?.paymentSecret,
|
|
157
208
|
returnUrl,
|
|
158
209
|
continueToPayment,
|
|
210
|
+
hold,
|
|
211
|
+
holdExpiredError,
|
|
212
|
+
retryHold,
|
|
213
|
+
isRetryingHold: flow.isStarting,
|
|
159
214
|
};
|
|
160
215
|
|
|
161
216
|
return <OfferContext.Provider value={value}>{children}</OfferContext.Provider>;
|