@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.
Files changed (50) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
  3. package/src/data/queries/useAccountSettings.ts +15 -2
  4. package/src/data/queries/useAuthActions.ts +51 -7
  5. package/src/data/queries/useEvents.ts +66 -4
  6. package/src/data/queries/useMembership.ts +8 -2
  7. package/src/data/queries/useMyBookings.ts +12 -0
  8. package/src/data/queries/useMyTickets.ts +112 -0
  9. package/src/data/queries/useOrders.ts +10 -0
  10. package/src/data/queries/usePassTransfers.ts +73 -13
  11. package/src/server/index.ts +52 -0
  12. package/src/types/models.ts +151 -0
  13. package/src/ui/format/_tests/attendees.spec.ts +231 -0
  14. package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
  15. package/src/ui/format/attendees.ts +187 -0
  16. package/src/ui/format/membershipGate.ts +209 -0
  17. package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
  18. package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
  19. package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
  20. package/src/ui/headless/checkout/inventoryHold.ts +83 -0
  21. package/src/ui/headless/checkout/useCheckout.ts +72 -0
  22. package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
  23. package/src/ui/headless/event/useEventCheckout.ts +133 -2
  24. package/src/ui/headless/event/usePresaleCode.ts +181 -0
  25. package/src/ui/headless/index.ts +25 -0
  26. package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
  27. package/src/ui/headless/offer/OfferContext.tsx +55 -0
  28. package/src/ui/index.ts +42 -0
  29. package/src/ui/styled/AccountDashboard.tsx +70 -8
  30. package/src/ui/styled/AddToCalendar.tsx +34 -10
  31. package/src/ui/styled/Checkout.tsx +18 -1
  32. package/src/ui/styled/CoachingConfirmation.tsx +4 -0
  33. package/src/ui/styled/CourseDetail.tsx +30 -1
  34. package/src/ui/styled/EventConfirmation.tsx +2 -0
  35. package/src/ui/styled/EventDetail.tsx +53 -22
  36. package/src/ui/styled/EventTickets.tsx +156 -5
  37. package/src/ui/styled/HoldNotice.tsx +192 -0
  38. package/src/ui/styled/MembershipGateNotice.tsx +159 -0
  39. package/src/ui/styled/OfferButton.tsx +23 -0
  40. package/src/ui/styled/PresaleCode.tsx +174 -0
  41. package/src/ui/styled/ProductDetail.tsx +75 -5
  42. package/src/ui/styled/ProductGrid.tsx +26 -0
  43. package/src/ui/styled/TicketTransfer.tsx +69 -40
  44. package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
  45. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
  46. package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
  47. package/src/utils/_tests/presaleCode.spec.ts +168 -0
  48. package/src/utils/_tests/structuredData.spec.ts +275 -0
  49. package/src/utils/presaleCode.ts +96 -0
  50. 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 { useEvent, useCreateEventOrder } from "../../../data/queries/useEvents";
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
- const { data: event, isLoading } = useEvent(slug);
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
+ }
@@ -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>;