@tribe-nest/forge 3.21.0 → 3.23.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 +2 -1
- package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
- package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
- package/src/data/queries/useAuthActions.ts +1 -1
- package/src/data/queries/useCheckouts.ts +5 -0
- package/src/data/queries/useEventWaitlist.ts +123 -5
- package/src/data/queries/useEvents.ts +82 -4
- package/src/data/queries/useMembership.ts +8 -2
- package/src/data/queries/useMyBookings.ts +37 -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/data/queries/usePaymentFlow.ts +84 -18
- package/src/data/queries/useShipping.ts +5 -0
- package/src/data/queries/useSubscriptions.ts +50 -4
- package/src/index.ts +5 -0
- package/src/server/index.ts +52 -0
- package/src/server/platform.ts +1 -1
- package/src/server/platformEvents.generated.ts +33 -33
- package/src/server/pwa.ts +2 -2
- package/src/types/models.ts +203 -5
- package/src/types/paystack-inline.d.ts +45 -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/_tests/ticketAvailability.spec.ts +283 -0
- package/src/ui/format/attendees.ts +187 -0
- package/src/ui/format/membershipGate.ts +209 -0
- package/src/ui/format/ticketAvailability.ts +226 -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/bundleCoupon.ts +14 -6
- package/src/ui/headless/checkout/inventoryHold.ts +83 -0
- package/src/ui/headless/checkout/useCheckout.ts +144 -8
- package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
- package/src/ui/headless/course/useCourseCheckout.ts +4 -0
- package/src/ui/headless/donation/Donation.tsx +10 -0
- package/src/ui/headless/donation/DonationContext.tsx +27 -1
- package/src/ui/headless/event/useEventCheckout.ts +203 -2
- package/src/ui/headless/event/usePresaleCode.ts +181 -0
- package/src/ui/headless/index.ts +25 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
- package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
- package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
- package/src/ui/headless/offer/Offer.tsx +19 -1
- package/src/ui/headless/offer/OfferContext.tsx +69 -1
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
- package/src/ui/headless/work/useWorkPortal.ts +3 -3
- package/src/ui/index.ts +64 -0
- package/src/ui/shell/diagnosticsGating.ts +3 -3
- package/src/ui/styled/AccountDashboard.tsx +143 -12
- package/src/ui/styled/AddToCalendar.tsx +34 -10
- package/src/ui/styled/AudioPlayer.tsx +1 -1
- package/src/ui/styled/BundleConfirmation.tsx +1 -1
- package/src/ui/styled/Checkout.tsx +42 -32
- package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
- package/src/ui/styled/CoachingBooking.tsx +11 -2
- package/src/ui/styled/CoachingConfirmation.tsx +7 -3
- package/src/ui/styled/CohortPage.tsx +1 -1
- package/src/ui/styled/ContactForm.tsx +1 -1
- package/src/ui/styled/CourseCheckout.tsx +10 -1
- package/src/ui/styled/CourseConfirmation.tsx +2 -2
- package/src/ui/styled/CourseDetail.tsx +30 -1
- package/src/ui/styled/DiscountCode.tsx +1 -1
- package/src/ui/styled/EmailListForm.tsx +1 -1
- package/src/ui/styled/EventConfirmation.tsx +3 -1
- package/src/ui/styled/EventDetail.tsx +53 -22
- package/src/ui/styled/EventSeriesDetail.tsx +1 -1
- package/src/ui/styled/EventTickets.tsx +284 -19
- package/src/ui/styled/EventWaitlist.tsx +23 -3
- package/src/ui/styled/HoldNotice.tsx +192 -0
- package/src/ui/styled/InvoicePayment.tsx +14 -5
- package/src/ui/styled/MembershipCheckout.tsx +20 -11
- package/src/ui/styled/MembershipGateNotice.tsx +159 -0
- package/src/ui/styled/OfferButton.tsx +24 -1
- package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
- package/src/ui/styled/PaystackPayButton.tsx +66 -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/ReviewForm.tsx +2 -2
- package/src/ui/styled/TicketTransfer.tsx +71 -42
- 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/ui/styled/community/CommunityFeed.tsx +1 -1
- package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
- package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
- package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
- package/src/utils/_tests/presaleCode.spec.ts +168 -0
- package/src/utils/_tests/structuredData.spec.ts +275 -0
- package/src/utils/membershipAccess.ts +3 -3
- package/src/utils/paystackCheckout.ts +277 -0
- package/src/utils/presaleCode.ts +96 -0
- package/src/utils/structuredData.ts +361 -27
- package/src/utils/ticketOrderOutcome.ts +1 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { ITicket } from "../../types/models";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* When a ticket tier may be BOUGHT, as a storefront has to draw it.
|
|
5
|
+
*
|
|
6
|
+
* ## The two defects this module exists to close
|
|
7
|
+
*
|
|
8
|
+
* Both are the same shape: the buyer's screen and the server disagreed, and the
|
|
9
|
+
* buyer discovered it only by being refused after doing the work.
|
|
10
|
+
*
|
|
11
|
+
* **(a) Held seats were shown as available.** Every storefront computed
|
|
12
|
+
* remaining as `quantity - sold`. The server sells on `quantity - sold - held`
|
|
13
|
+
* (`services/_core/inventoryHold.ts` — `quantity - sold - held >= n`), where
|
|
14
|
+
* `held` is inventory promised to somebody else's in-flight checkout. During an
|
|
15
|
+
* on-sale rush a fan saw "3 left", picked 2, filled in their details, and was
|
|
16
|
+
* refused at reserve because those three seats were in other people's carts.
|
|
17
|
+
*
|
|
18
|
+
* **(b) A scheduled on-sale rendered as buyable.** `availableFrom` shipped on
|
|
19
|
+
* the tier, is enforced by `evaluateTierSellability`, is published in
|
|
20
|
+
* `PUBLIC_TICKET_COLUMNS` — and appeared on NEITHER rendering stack. A tier
|
|
21
|
+
* going on sale Friday 10am was fully clickable on Wednesday, and the refusal
|
|
22
|
+
* arrived as `ticket_not_yet_on_sale` on the payment screen.
|
|
23
|
+
*
|
|
24
|
+
* ## Why it is pure, and why it lives in `format/`
|
|
25
|
+
*
|
|
26
|
+
* There are two rendering stacks — `apps/client` and the Forge SDK that code
|
|
27
|
+
* websites are built against — and availability arithmetic that says one thing
|
|
28
|
+
* on an artist's PWA and another on their website is the same bug twice. So the
|
|
29
|
+
* arithmetic and the words are decided once, here, with no React and no client;
|
|
30
|
+
* each stack owns only how it looks. `apps/client` imports this from
|
|
31
|
+
* `@tribe-nest/forge/ui`, exactly as it already does for the PWYW arithmetic
|
|
32
|
+
* and the members-only notice.
|
|
33
|
+
*
|
|
34
|
+
* ## The client is not the guard
|
|
35
|
+
*
|
|
36
|
+
* `createOrder` re-checks every one of these against the tier's own columns and
|
|
37
|
+
* refuses regardless of what a client sends. Nothing here makes a request safe.
|
|
38
|
+
* What it does is stop a buyer building a cart nobody will sell them.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Seats a buyer can actually take right now — the SERVER's arithmetic.
|
|
43
|
+
*
|
|
44
|
+
* `held` counts units inside somebody else's live checkout. It is optional on
|
|
45
|
+
* the type and absent-means-zero on purpose: a site pinned to an older API
|
|
46
|
+
* build that does not publish the column keeps exactly its previous behaviour
|
|
47
|
+
* rather than reading every tier as sold out.
|
|
48
|
+
*/
|
|
49
|
+
export const ticketSeatsAvailable = (
|
|
50
|
+
ticket: Pick<ITicket, "quantity" | "sold" | "held">,
|
|
51
|
+
/**
|
|
52
|
+
* Seats inside `held` that are held for THIS visitor (events 2.6b).
|
|
53
|
+
*
|
|
54
|
+
* A waitlist offer now takes a real inventory hold for the length of its
|
|
55
|
+
* claim window, which is the whole point — a stranger cannot take the seat
|
|
56
|
+
* the email promised. The consequence is that the tier reads sold out to
|
|
57
|
+
* everybody, INCLUDING the person it is being held for: their own reservation
|
|
58
|
+
* is in `held`, so the picker would refuse to let them add the seat they were
|
|
59
|
+
* offered, and the claim link would be unusable.
|
|
60
|
+
*
|
|
61
|
+
* Adding their own reservation back is not a loophole. It is spendable only
|
|
62
|
+
* by presenting the claim token, the server re-derives the same number from
|
|
63
|
+
* the hold it issued, and a visitor who invents a value simply gets refused
|
|
64
|
+
* at `createOrder` exactly as before.
|
|
65
|
+
*
|
|
66
|
+
* Zero by default, so every existing caller is byte-for-byte unchanged.
|
|
67
|
+
*/
|
|
68
|
+
reservedForYou = 0,
|
|
69
|
+
): number =>
|
|
70
|
+
Math.max(0, (ticket.quantity ?? 0) - (ticket.sold ?? 0) - (ticket.held ?? 0) + Math.max(0, reservedForYou));
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Seats that are not sold — held or otherwise.
|
|
74
|
+
*
|
|
75
|
+
* The difference between this and {@link ticketSeatsAvailable} is precisely the
|
|
76
|
+
* set of seats sitting in other people's checkouts, which is what separates
|
|
77
|
+
* "gone" from "gone for the next few minutes".
|
|
78
|
+
*/
|
|
79
|
+
export const ticketSeatsUnsold = (ticket: Pick<ITicket, "quantity" | "sold">): number =>
|
|
80
|
+
Math.max(0, (ticket.quantity ?? 0) - (ticket.sold ?? 0));
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Why a buyer cannot take a seat, when they cannot.
|
|
84
|
+
*
|
|
85
|
+
* `reserved_elsewhere` is deliberately NOT collapsed into `sold_out`. They are
|
|
86
|
+
* different facts with different right actions: a sold-out tier is over and the
|
|
87
|
+
* fan should join the waitlist or leave; a tier whose last seats are inside
|
|
88
|
+
* somebody's checkout may well be buyable in ten minutes, and telling that fan
|
|
89
|
+
* "SOLD OUT" loses a sale that was still there. It is also the state a rush
|
|
90
|
+
* produces most often, which is exactly when the wrong word costs the most.
|
|
91
|
+
*/
|
|
92
|
+
export type TicketSeatState = "available" | "reserved_elsewhere" | "sold_out";
|
|
93
|
+
|
|
94
|
+
export const ticketSeatState = (
|
|
95
|
+
ticket: Pick<ITicket, "quantity" | "sold" | "held">,
|
|
96
|
+
/** 2.6b — seats of `held` that are this visitor's own waitlist reservation. */
|
|
97
|
+
reservedForYou = 0,
|
|
98
|
+
): TicketSeatState => {
|
|
99
|
+
if (ticketSeatsAvailable(ticket, reservedForYou) > 0) return "available";
|
|
100
|
+
return ticketSeatsUnsold(ticket) > 0 ? "reserved_elsewhere" : "sold_out";
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The short word in the stepper, where "SOLD OUT" already lives.
|
|
105
|
+
*
|
|
106
|
+
* Kept to two words because it renders in a fixed-width slot between the − and
|
|
107
|
+
* + buttons; the sentence that explains it is {@link ticketSeatNotice}.
|
|
108
|
+
*/
|
|
109
|
+
export const ticketSeatLabel = (state: TicketSeatState): string | null => {
|
|
110
|
+
if (state === "sold_out") return "SOLD OUT";
|
|
111
|
+
if (state === "reserved_elsewhere") return "ON HOLD";
|
|
112
|
+
return null;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** The sentence under a tier that cannot be added, or `null` when it can. */
|
|
116
|
+
export const ticketSeatNotice = (state: TicketSeatState): string | null => {
|
|
117
|
+
if (state !== "reserved_elsewhere") return null;
|
|
118
|
+
return "The last seats are in someone else's checkout. They may be released in a few minutes, so check back.";
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Has this tier's on-sale time arrived?
|
|
123
|
+
*
|
|
124
|
+
* Mirrors `evaluateTierSellability`'s `not_yet_on_sale` branch exactly, down to
|
|
125
|
+
* the strict comparison: the server refuses while `now < availableFrom`, so a
|
|
126
|
+
* storefront that allowed the boundary second would produce the one refusal
|
|
127
|
+
* this whole module exists to prevent.
|
|
128
|
+
*/
|
|
129
|
+
export type TicketSaleWindow = { status: "open" } | { status: "scheduled"; startsAt: Date };
|
|
130
|
+
|
|
131
|
+
export const ticketSaleWindow = (
|
|
132
|
+
ticket: Pick<ITicket, "availableFrom">,
|
|
133
|
+
now: Date = new Date(),
|
|
134
|
+
): TicketSaleWindow => {
|
|
135
|
+
if (!ticket.availableFrom) return { status: "open" };
|
|
136
|
+
const startsAt = new Date(ticket.availableFrom);
|
|
137
|
+
if (Number.isNaN(startsAt.getTime())) return { status: "open" };
|
|
138
|
+
return now < startsAt ? { status: "scheduled", startsAt } : { status: "open" };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** `true` only while a scheduled tier's on-sale time is still in the future. */
|
|
142
|
+
export const isTicketScheduled = (
|
|
143
|
+
ticket: Pick<ITicket, "availableFrom">,
|
|
144
|
+
now: Date = new Date(),
|
|
145
|
+
): boolean => ticketSaleWindow(ticket, now).status === "scheduled";
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The buyer's local rendering of an on-sale instant.
|
|
149
|
+
*
|
|
150
|
+
* Buyer-local, not the event's timezone: this answers "when can I click buy?",
|
|
151
|
+
* which happens where the buyer is. The server compares absolute instants, so
|
|
152
|
+
* the two cannot disagree about WHEN — only about how it reads.
|
|
153
|
+
*
|
|
154
|
+
* Matches the format the waitlist claim deadline and the transfer panel already
|
|
155
|
+
* use, so three different "when" strings on one event page look like one voice.
|
|
156
|
+
*/
|
|
157
|
+
export const formatOnSaleAt = (startsAt: Date): string =>
|
|
158
|
+
startsAt.toLocaleString(undefined, {
|
|
159
|
+
weekday: "short",
|
|
160
|
+
day: "numeric",
|
|
161
|
+
month: "short",
|
|
162
|
+
hour: "numeric",
|
|
163
|
+
minute: "2-digit",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The badge for a tier that is announced but not yet buyable.
|
|
168
|
+
*
|
|
169
|
+
* Under an hour it counts down, because at that range "10:00" is worth less to
|
|
170
|
+
* a waiting fan than "in 12 minutes" and the tier list already re-renders every
|
|
171
|
+
* second for the expiry urgency. Above it, the absolute time is what someone
|
|
172
|
+
* plans their Friday around.
|
|
173
|
+
*
|
|
174
|
+
* A tier that is merely not-yet-on-sale stays VISIBLE (see
|
|
175
|
+
* `isTierPubliclyVisible`) — that is the point. Hiding it would make a
|
|
176
|
+
* scheduled on-sale indistinguishable from the tier not existing.
|
|
177
|
+
*/
|
|
178
|
+
export const onSaleBadge = (startsAt: Date, now: Date = new Date()): string => {
|
|
179
|
+
const diffMs = startsAt.getTime() - now.getTime();
|
|
180
|
+
if (diffMs <= 0) return "On sale now";
|
|
181
|
+
const minutes = Math.ceil(diffMs / 60_000);
|
|
182
|
+
if (minutes < 60) return `On sale in ${minutes} minute${minutes !== 1 ? "s" : ""}`;
|
|
183
|
+
return `On sale ${formatOnSaleAt(startsAt)}`;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** The sentence under a scheduled tier, spelling out the exact time. */
|
|
187
|
+
export const onSaleNotice = (startsAt: Date): string =>
|
|
188
|
+
`Tickets for this tier go on sale ${formatOnSaleAt(startsAt)}.`;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The most a buyer may put in the cart for one tier.
|
|
192
|
+
*
|
|
193
|
+
* `maxPerPerson` is a per-buyer cap and the seat count is a per-tier one; the
|
|
194
|
+
* cart is bounded by whichever bites first. Zero for anything not currently
|
|
195
|
+
* sellable, which is what disables the `+`.
|
|
196
|
+
*/
|
|
197
|
+
export const maxAddableTickets = (
|
|
198
|
+
/**
|
|
199
|
+
* `maxPerPerson` is widened to nullable HERE rather than on `ITicket`.
|
|
200
|
+
*
|
|
201
|
+
* The column is NOT NULL with a default, so `ITicket` is right about the
|
|
202
|
+
* database and must stay that way. But this function is handed payloads from
|
|
203
|
+
* two storefronts and older API builds, and its `?? seats` fallback exists
|
|
204
|
+
* precisely because the field can arrive absent. Typing the parameter for
|
|
205
|
+
* what it actually tolerates keeps that fallback reachable — and testable —
|
|
206
|
+
* without teaching the whole SDK that a non-null column is nullable.
|
|
207
|
+
*/
|
|
208
|
+
ticket: Pick<ITicket, "quantity" | "sold" | "held" | "availableFrom"> & {
|
|
209
|
+
maxPerPerson?: number | null;
|
|
210
|
+
},
|
|
211
|
+
now: Date = new Date(),
|
|
212
|
+
/**
|
|
213
|
+
* 2.6b — seats of `held` reserved for this visitor by a waitlist offer.
|
|
214
|
+
*
|
|
215
|
+
* Deliberately NOT exempt from `maxPerPerson`: the offer decides how many
|
|
216
|
+
* seats are held, the artist's per-person cap decides how many one person may
|
|
217
|
+
* hold at all, and an offer is not a licence to exceed the second. The server
|
|
218
|
+
* enforces the cap on the same order either way, so exempting it here would
|
|
219
|
+
* only move the refusal to the card screen.
|
|
220
|
+
*/
|
|
221
|
+
reservedForYou = 0,
|
|
222
|
+
): number => {
|
|
223
|
+
if (isTicketScheduled(ticket, now)) return 0;
|
|
224
|
+
const seats = ticketSeatsAvailable(ticket, reservedForYou);
|
|
225
|
+
return Math.min(seats, ticket.maxPerPerson ?? seats);
|
|
226
|
+
};
|
|
@@ -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
|
+
});
|
|
@@ -23,13 +23,19 @@ export type BundleCouponSummary = {
|
|
|
23
23
|
*/
|
|
24
24
|
appliedCoupon: { code: string; discountAmount: number } | null;
|
|
25
25
|
/**
|
|
26
|
-
* The re-minted
|
|
27
|
-
* element must move onto this
|
|
28
|
-
* pre-coupon amount. Null when there was
|
|
26
|
+
* The re-minted charge. Applying re-prices AND re-mints, so the payment
|
|
27
|
+
* element must move onto this session, because the previous one still quotes the
|
|
28
|
+
* pre-coupon amount. Null when there was nothing to replace (the bundle had
|
|
29
29
|
* not started payment) or when the bundle settled for free.
|
|
30
|
+
*
|
|
31
|
+
* Exactly one of the two session fields is set: `paymentSecret` on Stripe,
|
|
32
|
+
* `accessCode` + `checkoutUrl` on Paystack. A Paystack re-mint carries no
|
|
33
|
+
* secret at all, which is why the gate below cannot test that field alone.
|
|
30
34
|
*/
|
|
31
35
|
paymentUpdate: {
|
|
32
|
-
paymentSecret
|
|
36
|
+
paymentSecret?: string;
|
|
37
|
+
accessCode?: string;
|
|
38
|
+
checkoutUrl?: string;
|
|
33
39
|
paymentId: string;
|
|
34
40
|
chargedAmount: number;
|
|
35
41
|
chargedCurrency: string;
|
|
@@ -54,9 +60,11 @@ export function bundleCouponSummary(data: ApplyCheckoutCouponResult): BundleCoup
|
|
|
54
60
|
// not claimed as one — `discountAmount` still reports the reduction.
|
|
55
61
|
appliedCoupon: discountAmount > 0 && named ? { code: named.code, discountAmount } : null,
|
|
56
62
|
paymentUpdate:
|
|
57
|
-
data.paymentSecret && data.paymentId
|
|
63
|
+
(data.paymentSecret || data.accessCode || data.checkoutUrl) && data.paymentId
|
|
58
64
|
? {
|
|
59
|
-
paymentSecret: data.paymentSecret,
|
|
65
|
+
paymentSecret: data.paymentSecret || undefined,
|
|
66
|
+
accessCode: data.accessCode,
|
|
67
|
+
checkoutUrl: data.checkoutUrl,
|
|
60
68
|
paymentId: data.paymentId,
|
|
61
69
|
chargedAmount: data.chargedAmount,
|
|
62
70
|
chargedCurrency: data.chargedCurrency,
|
|
@@ -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
|
+
}
|