@tribe-nest/forge 3.20.0 → 3.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
- package/src/data/queries/useAccountSettings.ts +15 -2
- package/src/data/queries/useAuthActions.ts +51 -7
- package/src/data/queries/useEvents.ts +66 -4
- package/src/data/queries/useMembership.ts +8 -2
- package/src/data/queries/useMyBookings.ts +12 -0
- package/src/data/queries/useMyTickets.ts +112 -0
- package/src/data/queries/useOrders.ts +10 -0
- package/src/data/queries/usePassTransfers.ts +73 -13
- package/src/server/index.ts +52 -0
- package/src/types/models.ts +151 -0
- package/src/ui/format/_tests/attendees.spec.ts +231 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
- package/src/ui/format/attendees.ts +187 -0
- package/src/ui/format/membershipGate.ts +209 -0
- package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
- package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
- package/src/ui/headless/checkout/inventoryHold.ts +83 -0
- package/src/ui/headless/checkout/useCheckout.ts +72 -0
- package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
- package/src/ui/headless/event/useEventCheckout.ts +133 -2
- package/src/ui/headless/event/usePresaleCode.ts +181 -0
- package/src/ui/headless/index.ts +25 -0
- package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
- package/src/ui/headless/offer/OfferContext.tsx +55 -0
- package/src/ui/index.ts +42 -0
- package/src/ui/styled/AccountDashboard.tsx +70 -8
- package/src/ui/styled/AddToCalendar.tsx +34 -10
- package/src/ui/styled/Checkout.tsx +18 -1
- package/src/ui/styled/CoachingConfirmation.tsx +4 -0
- package/src/ui/styled/CourseDetail.tsx +30 -1
- package/src/ui/styled/EventConfirmation.tsx +2 -0
- package/src/ui/styled/EventDetail.tsx +53 -22
- package/src/ui/styled/EventTickets.tsx +156 -5
- package/src/ui/styled/HoldNotice.tsx +192 -0
- package/src/ui/styled/MembershipGateNotice.tsx +159 -0
- package/src/ui/styled/OfferButton.tsx +23 -0
- package/src/ui/styled/PresaleCode.tsx +174 -0
- package/src/ui/styled/ProductDetail.tsx +75 -5
- package/src/ui/styled/ProductGrid.tsx +26 -0
- package/src/ui/styled/TicketTransfer.tsx +69 -40
- package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
- package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
- package/src/utils/_tests/presaleCode.spec.ts +168 -0
- package/src/utils/_tests/structuredData.spec.ts +275 -0
- package/src/utils/presaleCode.ts +96 -0
- package/src/utils/structuredData.ts +361 -27
|
@@ -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`
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
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
|
+
}
|