@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
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildAddToCalendarLinks } from "../useAddToCalendar";
3
+
4
+ /**
5
+ * The Apple half of "add to calendar", which for a long time did not exist.
6
+ *
7
+ * Google and Microsoft publish TEMPLATE URLs — the whole event travels in the
8
+ * query string and no server is involved. Apple publishes nothing of the kind:
9
+ * Calendar takes a FILE, and Safari on iOS treats an https `.ics` as a file it
10
+ * cannot open. `webcal://` is the scheme iOS and macOS route to the Calendar
11
+ * app, so it is the only thing that makes an Apple option possible at all.
12
+ *
13
+ * This file is about the derivation, because that is where a mistake would be
14
+ * silent: a mangled scheme still renders a chip, and the failure only shows up
15
+ * on somebody's phone.
16
+ */
17
+ describe("buildAddToCalendarLinks — the Apple routes", () => {
18
+ const base = { title: "Night Show", start: "2026-03-15T20:00:00.000Z" };
19
+
20
+ const ICS = "https://api.tribenest.co/public/calendar/events/abc/tok.ics";
21
+
22
+ it("offers neither route when the host has no .ics to point at", () => {
23
+ const links = buildAddToCalendarLinks(base)!;
24
+
25
+ // A site pinned to an older API build must degrade to the three template
26
+ // providers, not draw a dead Apple chip.
27
+ expect(links.ics).toBeNull();
28
+ expect(links.webcal).toBeNull();
29
+ expect(links.google).toContain("calendar.google.com");
30
+ });
31
+
32
+ it("derives webcal:// from the https address when only that is supplied", () => {
33
+ const links = buildAddToCalendarLinks({ ...base, icsUrl: ICS })!;
34
+
35
+ expect(links.ics).toBe(ICS);
36
+ // Scheme-only swap: the path, the id and the HMAC token must survive
37
+ // untouched, or the link resolves to a different document (or to nothing).
38
+ expect(links.webcal).toBe("webcal://api.tribenest.co/public/calendar/events/abc/tok.ics");
39
+ });
40
+
41
+ it("prefers the server's own webcal URL over the derivation", () => {
42
+ const links = buildAddToCalendarLinks({
43
+ ...base,
44
+ icsUrl: ICS,
45
+ webcalUrl: "webcal://cdn.example.com/other.ics",
46
+ })!;
47
+
48
+ // The API composes both; if it ever puts them on different hosts, the
49
+ // client must not quietly out-vote it.
50
+ expect(links.webcal).toBe("webcal://cdn.example.com/other.ics");
51
+ });
52
+
53
+ it("derives from http as well as https", () => {
54
+ const links = buildAddToCalendarLinks({ ...base, icsUrl: "http://localhost:8000/public/calendar/events/a/t.ics" })!;
55
+ expect(links.webcal).toBe("webcal://localhost:8000/public/calendar/events/a/t.ics");
56
+ });
57
+
58
+ it("refuses to invent a webcal URL from something that is not absolute", () => {
59
+ // `calendarFeedUrl` falls back to a bare PATH when `API_URL` is unset, and
60
+ // "webcal:///public/calendar/…" is not an address any client can resolve.
61
+ // Better no Apple option than a broken one.
62
+ const links = buildAddToCalendarLinks({ ...base, icsUrl: "/public/calendar/events/abc/tok.ics" })!;
63
+
64
+ expect(links.ics).toBe("/public/calendar/events/abc/tok.ics");
65
+ expect(links.webcal).toBeNull();
66
+ });
67
+
68
+ it("passes an already-webcal address through unchanged", () => {
69
+ const links = buildAddToCalendarLinks({ ...base, webcalUrl: "webcal://api.tribenest.co/a.ics" })!;
70
+ expect(links.webcal).toBe("webcal://api.tribenest.co/a.ics");
71
+ // No https form was supplied, so no download is offered.
72
+ expect(links.ics).toBeNull();
73
+ });
74
+
75
+ it("leaves the three template providers untouched by any of this", () => {
76
+ const withIcs = buildAddToCalendarLinks({ ...base, icsUrl: ICS })!;
77
+ const without = buildAddToCalendarLinks(base)!;
78
+
79
+ expect(withIcs.google).toBe(without.google);
80
+ expect(withIcs.outlook).toBe(without.outlook);
81
+ expect(withIcs.office365).toBe(without.office365);
82
+ });
83
+ });
@@ -26,11 +26,29 @@ export type AddToCalendarInput = {
26
26
  /** Canonical page for the event; appended to the description body when present. */
27
27
  url?: string | null;
28
28
  /**
29
- * Absolute URL to a downloadable `.ics` (what Apple Calendar / Outlook desktop
30
- * want). Forge does not synthesize this the host passes the endpoint it has.
31
- * When absent, `ics` comes back `null` and no Apple option is offered.
29
+ * Absolute URL to a downloadable `.ics` what Outlook desktop, Thunderbird
30
+ * and a Mac that would rather have the file all want.
31
+ *
32
+ * Forge does not synthesize this and cannot: the platform's `.ics` address
33
+ * carries a keyed HMAC, so it is deliberately not derivable from an event id.
34
+ * The host passes the field the API gave it (`calendarUrl`). When absent,
35
+ * `ics` comes back `null` and no download option is offered.
32
36
  */
33
37
  icsUrl?: string | null;
38
+ /**
39
+ * The same `.ics`, under `webcal://` — the API's `calendarWebcalUrl`.
40
+ *
41
+ * **This is the Apple route, and there is no other.** Apple publishes no
42
+ * "add to calendar" template URL the way Google and Microsoft do, which is
43
+ * exactly why every control in this product used to offer three providers and
44
+ * no Apple option. Safari on iOS treats an https `.ics` as a file it cannot
45
+ * open; the same address under `webcal:` is handed straight to Calendar.
46
+ *
47
+ * Optional, and derived from {@link icsUrl} when omitted — a host on an older
48
+ * API build that only carries the https form still gets a working Apple
49
+ * option, because the two differ only by scheme.
50
+ */
51
+ webcalUrl?: string | null;
34
52
  };
35
53
 
36
54
  export type AddToCalendarLinks = {
@@ -42,8 +60,14 @@ export type AddToCalendarLinks = {
42
60
  office365: string;
43
61
  /** Yahoo Calendar. */
44
62
  yahoo: string;
45
- /** Pass-through of `icsUrl` — the Apple/desktop route. `null` when not supplied. */
63
+ /** Pass-through of `icsUrl` — the download route. `null` when not supplied. */
46
64
  ics: string | null;
65
+ /**
66
+ * The `webcal://` address, for the Apple option. Falls back to `icsUrl` with
67
+ * its scheme swapped, so supplying either one is enough. `null` when neither
68
+ * is available.
69
+ */
70
+ webcal: string | null;
47
71
  startsAt: Date;
48
72
  endsAt: Date;
49
73
  };
@@ -97,6 +121,20 @@ export function stripHtml(html: string, maxLength: number = MAX_DESCRIPTION_CHAR
97
121
 
98
122
  const enc = encodeURIComponent;
99
123
 
124
+ /**
125
+ * The `webcal://` form, preferring what the host was given.
126
+ *
127
+ * The swap is scheme-only and is applied to an https address, never to an
128
+ * arbitrary string: a relative or already-`webcal:` URL is passed through
129
+ * untouched rather than being mangled into something a calendar client would
130
+ * refuse.
131
+ */
132
+ function toWebcal(webcalUrl?: string | null, icsUrl?: string | null): string | null {
133
+ if (webcalUrl) return webcalUrl;
134
+ if (!icsUrl) return null;
135
+ return /^https?:\/\//i.test(icsUrl) ? icsUrl.replace(/^https?:\/\//i, "webcal://") : null;
136
+ }
137
+
100
138
  /**
101
139
  * Build every provider URL for one calendar entry. Returns `null` when the
102
140
  * start is missing or unparseable — callers render nothing rather than a link
@@ -156,6 +194,7 @@ export function buildAddToCalendarLinks(input: AddToCalendarInput): AddToCalenda
156
194
  `&desc=${enc(body)}` +
157
195
  `&in_loc=${enc(location)}`,
158
196
  ics: input.icsUrl || null,
197
+ webcal: toWebcal(input.webcalUrl, input.icsUrl),
159
198
  startsAt,
160
199
  endsAt,
161
200
  };
@@ -177,6 +216,7 @@ export function useAddToCalendar(input: AddToCalendarInput | null | undefined):
177
216
  const location = input?.location;
178
217
  const url = input?.url;
179
218
  const icsUrl = input?.icsUrl;
219
+ const webcalUrl = input?.webcalUrl;
180
220
 
181
221
  return useMemo(() => {
182
222
  if (!start) return null;
@@ -189,6 +229,7 @@ export function useAddToCalendar(input: AddToCalendarInput | null | undefined):
189
229
  location,
190
230
  url,
191
231
  icsUrl,
232
+ webcalUrl,
192
233
  });
193
- }, [title, start, end, defaultDurationMinutes, description, location, url, icsUrl]);
234
+ }, [title, start, end, defaultDurationMinutes, description, location, url, icsUrl, webcalUrl]);
194
235
  }
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ HOLD_EXPIRED_CODE,
4
+ formatHoldRemaining,
5
+ holdExpiredMessage,
6
+ holdMsRemaining,
7
+ isHoldExpiredError,
8
+ } from "../inventoryHold";
9
+
10
+ /**
11
+ * The inventory hold, as both rendering stacks read it.
12
+ *
13
+ * Every case here is a bug the checkout shipped with before this existed: a
14
+ * buyer told "sold out" for an item that was not sold out, a countdown drawn
15
+ * over an order that had no reservation at all, and a timer that read "0:00"
16
+ * for a whole second while the payment was still perfectly good.
17
+ *
18
+ * Pure, so they run with no provider tree — the same reason `bundleCoupon`'s
19
+ * helpers live outside the hook.
20
+ */
21
+
22
+ const apiError = (data: Record<string, unknown>, status = 409) => ({ response: { status, data } });
23
+
24
+ describe("isHoldExpiredError", () => {
25
+ it("recognises the 409 the hold endpoints throw", () => {
26
+ expect(isHoldExpiredError(apiError({ code: HOLD_EXPIRED_CODE, message: "…expired…" }))).toBe(true);
27
+ });
28
+
29
+ it("does NOT treat sold-out as an expired hold", () => {
30
+ // The whole point of splitting the error out: sold-out has no retry that
31
+ // can work, an expired hold usually does. Offering the wrong one is the
32
+ // regression this guards.
33
+ expect(isHoldExpiredError(apiError({ message: "This ticket is sold out." }, 400))).toBe(false);
34
+ });
35
+
36
+ it("does not fire on a bare 409 with no code", () => {
37
+ // A 409 is a generic conflict any future endpoint could answer with;
38
+ // switching on the status alone would offer a retry that cannot help.
39
+ expect(isHoldExpiredError(apiError({ message: "Conflict" }))).toBe(false);
40
+ });
41
+
42
+ it("is safe on the shapes an unexpected failure actually arrives as", () => {
43
+ expect(isHoldExpiredError(undefined)).toBe(false);
44
+ expect(isHoldExpiredError(null)).toBe(false);
45
+ expect(isHoldExpiredError(new Error("Network Error"))).toBe(false);
46
+ expect(isHoldExpiredError({})).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe("holdExpiredMessage", () => {
51
+ it("prefers the API's own localised words", () => {
52
+ const message = "Your ticket reservation expired before payment finished.";
53
+ expect(holdExpiredMessage(apiError({ code: HOLD_EXPIRED_CODE, message }), "fallback")).toBe(message);
54
+ });
55
+
56
+ it("falls back only when the response carried nothing", () => {
57
+ expect(holdExpiredMessage(apiError({ code: HOLD_EXPIRED_CODE }), "fallback")).toBe("fallback");
58
+ expect(holdExpiredMessage(new Error("boom"), "fallback")).toBe("fallback");
59
+ });
60
+ });
61
+
62
+ describe("holdMsRemaining", () => {
63
+ const now = Date.parse("2026-08-09T12:00:00.000Z");
64
+
65
+ it("measures the window from now", () => {
66
+ expect(holdMsRemaining("2026-08-09T12:10:00.000Z", now)).toBe(600_000);
67
+ });
68
+
69
+ it("returns null — NOT zero — when no hold was taken", () => {
70
+ // `null` is "there is no reservation" (holds switched off for the profile,
71
+ // a digital-only cart, an already-settled free order); `0` is "a real one
72
+ // ran out". A surface that conflated them would draw an expired countdown
73
+ // on every order for every profile with the feature off.
74
+ expect(holdMsRemaining(null, now)).toBeNull();
75
+ expect(holdMsRemaining(undefined, now)).toBeNull();
76
+ expect(holdMsRemaining("", now)).toBeNull();
77
+ });
78
+
79
+ it("returns null for an unparseable instant rather than a bogus countdown", () => {
80
+ expect(holdMsRemaining("not-a-date", now)).toBeNull();
81
+ });
82
+
83
+ it("clamps a past instant to zero instead of counting up", () => {
84
+ expect(holdMsRemaining("2026-08-09T11:59:00.000Z", now)).toBe(0);
85
+ });
86
+ });
87
+
88
+ describe("formatHoldRemaining", () => {
89
+ it("renders m:ss", () => {
90
+ expect(formatHoldRemaining(600_000)).toBe("10:00");
91
+ expect(formatHoldRemaining(65_000)).toBe("1:05");
92
+ expect(formatHoldRemaining(9_000)).toBe("0:09");
93
+ });
94
+
95
+ it("rounds UP, so the clock only reads 0:00 when it is genuinely over", () => {
96
+ // Rounding down put "0:00" on screen for the last whole second of a hold
97
+ // that was still perfectly valid — a small lie the buyer acts on.
98
+ expect(formatHoldRemaining(1)).toBe("0:01");
99
+ expect(formatHoldRemaining(999)).toBe("0:01");
100
+ expect(formatHoldRemaining(0)).toBe("0:00");
101
+ });
102
+
103
+ it("grows to h:mm:ss for a long window", () => {
104
+ expect(formatHoldRemaining(3_600_000)).toBe("1:00:00");
105
+ expect(formatHoldRemaining(3_725_000)).toBe("1:02:05");
106
+ });
107
+
108
+ it("never renders a negative clock", () => {
109
+ expect(formatHoldRemaining(-5_000)).toBe("0:00");
110
+ });
111
+ });
@@ -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
+ }