@tribe-nest/forge 3.21.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/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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inventory hold, as a buyer experiences it.
|
|
3
|
+
*
|
|
4
|
+
* The server reserves stock while a buyer is on the card form and reports when
|
|
5
|
+
* that reservation lapses (`holdExpiresAt`, ISO-8601, on every checkout-create
|
|
6
|
+
* and every `start-payment` response). When it lapses before the payment lands,
|
|
7
|
+
* `start-payment` answers **409 `INVENTORY_HOLD_EXPIRED`** — deliberately NOT
|
|
8
|
+
* the sold-out error, because "your reservation ran out" and "there are none
|
|
9
|
+
* left" lead the buyer to different next actions and only the first one has a
|
|
10
|
+
* retry that works.
|
|
11
|
+
*
|
|
12
|
+
* None of that was rendered anywhere. This module is the shared, provider-free
|
|
13
|
+
* half of rendering it, so both stacks (the client PWA and Forge's own styled
|
|
14
|
+
* drop-ins) count the same clock and read the same error.
|
|
15
|
+
*
|
|
16
|
+
* Everything here is pure — no React, no provider tree — so it is unit-testable
|
|
17
|
+
* on its own, the same reason `bundleCoupon.ts` sits beside it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The machine-readable code the API stamps on an expired-hold refusal. */
|
|
21
|
+
export const HOLD_EXPIRED_CODE = "INVENTORY_HOLD_EXPIRED";
|
|
22
|
+
|
|
23
|
+
type MaybeApiError = {
|
|
24
|
+
response?: { status?: number; data?: { code?: string; message?: string } };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Is this failure "your reservation lapsed" rather than anything else?
|
|
29
|
+
*
|
|
30
|
+
* Switches on `code`, never on prose and never on the bare 409 — a 409 alone is
|
|
31
|
+
* a generic conflict a future endpoint could reuse, and treating one as an
|
|
32
|
+
* expired hold would offer a retry that cannot help.
|
|
33
|
+
*/
|
|
34
|
+
export function isHoldExpiredError(error: unknown): boolean {
|
|
35
|
+
return (error as MaybeApiError)?.response?.data?.code === HOLD_EXPIRED_CODE;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The API's own words for a lapsed hold, already localised server-side
|
|
40
|
+
* (`errors.order.hold_expired` / `errors.event.hold_expired`).
|
|
41
|
+
*
|
|
42
|
+
* The fallback is only reached if the response carried no message at all; it is
|
|
43
|
+
* never used to *replace* the server's message, which is the one that names the
|
|
44
|
+
* right noun ("basket" vs "tickets").
|
|
45
|
+
*/
|
|
46
|
+
export function holdExpiredMessage(error: unknown, fallback: string): string {
|
|
47
|
+
return (error as MaybeApiError)?.response?.data?.message || fallback;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Milliseconds until the hold lapses, or `null` when there is no hold.
|
|
52
|
+
*
|
|
53
|
+
* `null` and `0` are different facts and the UI must not conflate them:
|
|
54
|
+
* `holdExpiresAt` is absent for a free/already-settled order and for every
|
|
55
|
+
* profile with the holds switch off, and inventing a countdown there would
|
|
56
|
+
* promise a reservation nobody took. Zero means a real hold that has run out.
|
|
57
|
+
*
|
|
58
|
+
* Clamped at zero so a stale or clock-skewed instant reads as lapsed rather
|
|
59
|
+
* than as a negative timer.
|
|
60
|
+
*/
|
|
61
|
+
export function holdMsRemaining(holdExpiresAt: string | null | undefined, now: number): number | null {
|
|
62
|
+
if (!holdExpiresAt) return null;
|
|
63
|
+
const ms = Date.parse(holdExpiresAt);
|
|
64
|
+
if (Number.isNaN(ms)) return null;
|
|
65
|
+
return Math.max(0, ms - now);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `m:ss` for the countdown, and `h:mm:ss` above an hour.
|
|
70
|
+
*
|
|
71
|
+
* Rounds UP, so a hold with 1ms left reads "0:01" and the timer only shows
|
|
72
|
+
* "0:00" at the instant it is genuinely over — a countdown that sits on zero
|
|
73
|
+
* for a whole second while payment is still accepted is a small lie the buyer
|
|
74
|
+
* acts on.
|
|
75
|
+
*/
|
|
76
|
+
export function formatHoldRemaining(msRemaining: number): string {
|
|
77
|
+
const total = Math.ceil(Math.max(0, msRemaining) / 1000);
|
|
78
|
+
const seconds = total % 60;
|
|
79
|
+
const minutes = Math.floor(total / 60) % 60;
|
|
80
|
+
const hours = Math.floor(total / 3600);
|
|
81
|
+
const mm = hours > 0 ? String(minutes).padStart(2, "0") : String(minutes);
|
|
82
|
+
return `${hours > 0 ? `${hours}:` : ""}${mm}:${String(seconds).padStart(2, "0")}`;
|
|
83
|
+
}
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
type ApplyCheckoutCouponResult,
|
|
19
19
|
} from "../../../data/queries/useCheckouts";
|
|
20
20
|
import { bundleCouponSummary, bundleReturnUrl } from "./bundleCoupon";
|
|
21
|
+
import { holdExpiredMessage, isHoldExpiredError } from "./inventoryHold";
|
|
22
|
+
import { useInventoryHold } from "./useInventoryHold";
|
|
21
23
|
import { readAttributionRef } from "../../../utils/attribution";
|
|
22
24
|
import { readLanding } from "../../../utils/landing";
|
|
23
25
|
import { ProductDeliveryType, PaymentProviderName, type ApiError, type PublicTaxQuote } from "../../../types/models";
|
|
@@ -289,6 +291,58 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
289
291
|
|
|
290
292
|
const orderId = created?.orderId ?? null;
|
|
291
293
|
|
|
294
|
+
// ── Inventory hold ──────────────────────────────────────────────────────────
|
|
295
|
+
/**
|
|
296
|
+
* The reservation clock the buyer is counting down.
|
|
297
|
+
*
|
|
298
|
+
* `start-payment` RESTARTS the hold and reports the new instant, so its figure
|
|
299
|
+
* supersedes the one `createOrder` returned — that one was measured before the
|
|
300
|
+
* buyer had even seen a payment field. The create-time value bridges the
|
|
301
|
+
* moment in between, which for a slow `start-payment` is the whole of the
|
|
302
|
+
* buyer's first impression of the page.
|
|
303
|
+
*
|
|
304
|
+
* A bundle has no create-time figure at all (`POST /public/checkouts` takes
|
|
305
|
+
* the holds but does not report a window), so it is start-payment or nothing —
|
|
306
|
+
* which is fine, because a bundle always starts payment immediately.
|
|
307
|
+
*
|
|
308
|
+
* `undefined` on an older API and `null` whenever no hold was taken; both mean
|
|
309
|
+
* "render nothing", which is exactly today's behaviour.
|
|
310
|
+
*/
|
|
311
|
+
const holdExpiresAt = flow.result?.holdExpiresAt ?? created?.holdExpiresAt ?? null;
|
|
312
|
+
const hold = useInventoryHold(holdExpiresAt);
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The reservation ran out before the payment landed (409
|
|
316
|
+
* `INVENTORY_HOLD_EXPIRED`), which is NOT sold-out — the items may well still
|
|
317
|
+
* be there, and re-running `start-payment` re-acquires the very same units
|
|
318
|
+
* when they are. Told apart from every other start failure so the buyer is
|
|
319
|
+
* offered the retry that actually works instead of a dead end.
|
|
320
|
+
*/
|
|
321
|
+
const isHoldExpired = isHoldExpiredError(flow.error);
|
|
322
|
+
const holdExpiredError = isHoldExpired
|
|
323
|
+
? holdExpiredMessage(flow.error, "Your basket reservation expired before payment finished. Please try again.")
|
|
324
|
+
: "";
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Take the reservation again on the SAME order or bundle.
|
|
328
|
+
*
|
|
329
|
+
* The cheapest recovery there is: nothing about the cart has changed, so if
|
|
330
|
+
* the units are still free the buyer gets a fresh window and the card form
|
|
331
|
+
* they were already looking at. If they are genuinely gone, another 409 says
|
|
332
|
+
* so — and that is the point at which "change your selection" is the honest
|
|
333
|
+
* next step, not before.
|
|
334
|
+
*/
|
|
335
|
+
const retryHold = useCallback(async () => {
|
|
336
|
+
if (!created?.orderId && !checkoutId) return;
|
|
337
|
+
try {
|
|
338
|
+
await flow.start();
|
|
339
|
+
} catch {
|
|
340
|
+
// The refusal is already on `flow.error`; a rethrow here would only turn a
|
|
341
|
+
// handled state into an unhandled rejection.
|
|
342
|
+
}
|
|
343
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
344
|
+
}, [created?.orderId, checkoutId]);
|
|
345
|
+
|
|
292
346
|
// A coupon re-issues the payment intent, so its update supersedes the initial
|
|
293
347
|
// start-payment result. Otherwise use the started payment once both the order
|
|
294
348
|
// and start-payment have resolved.
|
|
@@ -589,6 +643,24 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
589
643
|
ticketItems,
|
|
590
644
|
isCreatingOrder: createOrder.isPending || flow.isStarting,
|
|
591
645
|
startError,
|
|
646
|
+
// ── Inventory hold ─────────────────────────────────────────────────────────
|
|
647
|
+
/**
|
|
648
|
+
* The live reservation: `isHeld` + `label` while it runs, `hasLapsed` once
|
|
649
|
+
* the clock hits zero. Every field is null/false when no hold was taken, so
|
|
650
|
+
* a surface guarding on `isHeld || hasLapsed` is unchanged for the profiles
|
|
651
|
+
* that have holds switched off.
|
|
652
|
+
*/
|
|
653
|
+
hold,
|
|
654
|
+
/**
|
|
655
|
+
* The server said the reservation expired (409 `INVENTORY_HOLD_EXPIRED`) —
|
|
656
|
+
* its own words, already localised. Empty for every other failure, so this
|
|
657
|
+
* can never be mistaken for sold-out.
|
|
658
|
+
*/
|
|
659
|
+
holdExpiredError,
|
|
660
|
+
isHoldExpired,
|
|
661
|
+
/** Re-take the reservation on the same order/bundle. See `retryHold`. */
|
|
662
|
+
retryHold,
|
|
663
|
+
isRetryingHold: flow.isStarting,
|
|
592
664
|
provider,
|
|
593
665
|
isPaystack: provider === PaymentProviderName.Paystack,
|
|
594
666
|
paymentSecret: effectivePayment?.paymentSecret,
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { formatHoldRemaining, holdMsRemaining } from "./inventoryHold";
|
|
3
|
+
|
|
4
|
+
export interface InventoryHoldState {
|
|
5
|
+
/**
|
|
6
|
+
* True only while a real reservation is running. False both when there is no
|
|
7
|
+
* hold at all and once one has run out — the two are told apart by
|
|
8
|
+
* `hasLapsed`, and no surface should draw a timer off this alone.
|
|
9
|
+
*/
|
|
10
|
+
isHeld: boolean;
|
|
11
|
+
/** True when a hold existed and its clock reached zero on this device. */
|
|
12
|
+
hasLapsed: boolean;
|
|
13
|
+
/** The instant the API reported, unchanged; `null` when no hold was taken. */
|
|
14
|
+
expiresAt: string | null;
|
|
15
|
+
/** Milliseconds left, or `null` when there is no hold. */
|
|
16
|
+
msRemaining: number | null;
|
|
17
|
+
/** Whole seconds left, or `null` when there is no hold. */
|
|
18
|
+
secondsRemaining: number | null;
|
|
19
|
+
/** `m:ss` (or `h:mm:ss`) for display; `null` when there is nothing to count. */
|
|
20
|
+
label: string | null;
|
|
21
|
+
/** Under two minutes — the point at which a surface should stop being subtle. */
|
|
22
|
+
isUrgent: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UseInventoryHoldOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Fired once, when a running clock reaches zero. For telling a flow to stop
|
|
28
|
+
* describing the reservation as live; NOT for tearing the payment form down.
|
|
29
|
+
*/
|
|
30
|
+
onLapse?: () => void;
|
|
31
|
+
/** Tick interval, ms. Only for tests — a second is what a countdown means. */
|
|
32
|
+
intervalMs?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Count down an inventory hold.
|
|
37
|
+
*
|
|
38
|
+
* ## What it does at zero, and why
|
|
39
|
+
*
|
|
40
|
+
* It stops, and it flips `hasLapsed`. A timer that reaches 0:00 and then keeps
|
|
41
|
+
* sitting there is worse than no timer: it has told the buyer something has
|
|
42
|
+
* happened and then refuses to say what. So the honest end state is a *changed
|
|
43
|
+
* statement* — "your reservation has ended" — which the calling surface renders
|
|
44
|
+
* in place of the clock, with the same recovery action a 409 gets.
|
|
45
|
+
*
|
|
46
|
+
* It deliberately does **not** hide or disable the payment form. This clock is
|
|
47
|
+
* the buyer's device clock against an instant the server chose; it can be
|
|
48
|
+
* skewed, and the server may still accept the payment (`start-payment`
|
|
49
|
+
* re-acquires the same units when they are still free). Refusing a charge the
|
|
50
|
+
* server would have taken loses a real sale to defend a display detail. The
|
|
51
|
+
* authority stays where it belongs — the 409, when it comes.
|
|
52
|
+
*
|
|
53
|
+
* ## Degrading to today's behaviour
|
|
54
|
+
*
|
|
55
|
+
* Holds are behind a per-profile switch that most profiles have off, and a free
|
|
56
|
+
* or already-settled order never carries one either. In all of those cases
|
|
57
|
+
* `holdExpiresAt` is `null`/absent, every field here is null/false, and a
|
|
58
|
+
* surface that guards on `isHeld || hasLapsed` renders exactly what it renders
|
|
59
|
+
* today.
|
|
60
|
+
*/
|
|
61
|
+
export function useInventoryHold(
|
|
62
|
+
holdExpiresAt: string | null | undefined,
|
|
63
|
+
opts: UseInventoryHoldOptions = {},
|
|
64
|
+
): InventoryHoldState {
|
|
65
|
+
const { onLapse, intervalMs = 1000 } = opts;
|
|
66
|
+
const [msRemaining, setMsRemaining] = useState<number | null>(() => holdMsRemaining(holdExpiresAt, Date.now()));
|
|
67
|
+
// Keeps the callback out of the effect's dependencies, so an inline arrow
|
|
68
|
+
// from the caller does not restart the interval on every render.
|
|
69
|
+
const onLapseRef = useRef(onLapse);
|
|
70
|
+
onLapseRef.current = onLapse;
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const initial = holdMsRemaining(holdExpiresAt, Date.now());
|
|
74
|
+
setMsRemaining(initial);
|
|
75
|
+
// No hold, or one that was already over when we first saw it (a resumed tab,
|
|
76
|
+
// a skewed clock): nothing to tick. The already-over case still reports
|
|
77
|
+
// `hasLapsed` below — it just never animated down to it.
|
|
78
|
+
if (initial === null || initial <= 0) return;
|
|
79
|
+
|
|
80
|
+
const id = setInterval(() => {
|
|
81
|
+
const next = holdMsRemaining(holdExpiresAt, Date.now());
|
|
82
|
+
setMsRemaining(next);
|
|
83
|
+
if (next !== null && next <= 0) {
|
|
84
|
+
clearInterval(id);
|
|
85
|
+
onLapseRef.current?.();
|
|
86
|
+
}
|
|
87
|
+
}, intervalMs);
|
|
88
|
+
return () => clearInterval(id);
|
|
89
|
+
}, [holdExpiresAt, intervalMs]);
|
|
90
|
+
|
|
91
|
+
const hasHold = msRemaining !== null;
|
|
92
|
+
const hasLapsed = hasHold && msRemaining <= 0;
|
|
93
|
+
const isHeld = hasHold && msRemaining > 0;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
isHeld,
|
|
97
|
+
hasLapsed,
|
|
98
|
+
expiresAt: holdExpiresAt ?? null,
|
|
99
|
+
msRemaining,
|
|
100
|
+
secondsRemaining: hasHold ? Math.ceil(msRemaining / 1000) : null,
|
|
101
|
+
label: isHeld ? formatHoldRemaining(msRemaining) : null,
|
|
102
|
+
isUrgent: isHeld && msRemaining <= 120_000,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -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
|
+
}
|