@tribe-nest/forge 3.2.0 → 3.9.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 (56) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +122 -0
  3. package/src/data/queries/_tests/passTransfers.spec.ts +89 -0
  4. package/src/data/queries/_tests/walletPass.spec.ts +159 -0
  5. package/src/data/queries/useCheckouts.ts +84 -1
  6. package/src/data/queries/useCoachingAvailability.ts +18 -3
  7. package/src/data/queries/useCourses.ts +42 -1
  8. package/src/data/queries/useEventWaitlist.ts +429 -0
  9. package/src/data/queries/useEvents.ts +15 -1
  10. package/src/data/queries/useMyBookings.ts +211 -0
  11. package/src/data/queries/useMyTickets.ts +154 -0
  12. package/src/data/queries/usePassTransfers.ts +318 -0
  13. package/src/data/queries/usePaymentFlow.ts +12 -0
  14. package/src/data/queries/useWalletPass.ts +236 -0
  15. package/src/data/queries/useWebsite.ts +6 -0
  16. package/src/index.ts +14 -0
  17. package/src/server/_tests/siteBootstrap.spec.ts +131 -0
  18. package/src/server/index.ts +122 -6
  19. package/src/types/diagnostics.ts +49 -0
  20. package/src/types/models.ts +66 -0
  21. package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
  22. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
  23. package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
  24. package/src/ui/headless/checkout/useCheckout.ts +156 -8
  25. package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
  26. package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
  27. package/src/ui/headless/coupon/useCouponField.ts +164 -0
  28. package/src/ui/headless/course/useCourseCheckout.ts +113 -18
  29. package/src/ui/headless/event/useEventCheckout.ts +53 -2
  30. package/src/ui/headless/index.ts +15 -0
  31. package/src/ui/index.ts +26 -0
  32. package/src/ui/shell/PoweredBy.tsx +62 -0
  33. package/src/ui/shell/PreviewDiagnostics.tsx +80 -0
  34. package/src/ui/shell/TribeNestApp.tsx +39 -1
  35. package/src/ui/shell/diagnosticsGating.spec.ts +102 -0
  36. package/src/ui/shell/diagnosticsGating.ts +90 -0
  37. package/src/ui/shell/shellGating.spec.ts +60 -1
  38. package/src/ui/shell/shellGating.ts +47 -0
  39. package/src/ui/styled/AccountDashboard.tsx +598 -1
  40. package/src/ui/styled/AddToCalendar.tsx +104 -0
  41. package/src/ui/styled/Checkout.tsx +45 -14
  42. package/src/ui/styled/CoachingBooking.tsx +28 -8
  43. package/src/ui/styled/CoachingConfirmation.tsx +12 -0
  44. package/src/ui/styled/CourseCheckout.tsx +49 -18
  45. package/src/ui/styled/DiscountCode.tsx +206 -0
  46. package/src/ui/styled/EventConfirmation.tsx +68 -22
  47. package/src/ui/styled/EventDetail.tsx +24 -5
  48. package/src/ui/styled/EventTickets.tsx +49 -5
  49. package/src/ui/styled/EventWaitlist.tsx +448 -0
  50. package/src/ui/styled/TicketTransfer.tsx +393 -0
  51. package/src/ui/styled/WalletPassButtons.tsx +208 -0
  52. package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
  53. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
  54. package/src/ui/styled/_tests/WalletPassButtons.spec.tsx +223 -0
  55. package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
  56. package/src/utils/ticketOrderOutcome.ts +125 -0
@@ -0,0 +1,429 @@
1
+ import { useMemo, useSyncExternalStore } from "react";
2
+ import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { useForge } from "../../provider/ForgeProvider";
4
+ import { useEvents } from "./useEvents";
5
+ import type { IEvent, ITicket } from "../../types/models";
6
+
7
+ /**
8
+ * Events 2.6 — the buyer's side of the ticket waitlist.
9
+ *
10
+ * Lives in Forge rather than in either app because both rendering surfaces —
11
+ * the client PWA and code websites — need exactly this, and a second copy is
12
+ * how the two drift (the same reason `useMyTickets` lives here).
13
+ *
14
+ * ## The endpoints
15
+ *
16
+ * ```
17
+ * POST /public/events/:eventId/waitlist join → WaitlistEntry (201)
18
+ * GET /public/events/:eventId/waitlist mine → WaitlistEntry[] (401 if anonymous)
19
+ * GET /public/events/:eventId/waitlist/:entryId one → WaitlistEntry
20
+ * DELETE /public/events/:eventId/waitlist/:entryId leave → { id, status, leftAt }
21
+ * ```
22
+ *
23
+ * `:eventId` is the event's **UUID**, not its slug — the waitlist service
24
+ * resolves the event by id only (unlike `GET /public/events/:idOrSlug`). Every
25
+ * hook here therefore takes an id, and the UI passes `event.id` off an event it
26
+ * already loaded.
27
+ *
28
+ * ## Three rules the server owns, which this file deliberately does not mirror
29
+ *
30
+ * 1. **Eligibility.** Joining is refused unless the tier has no seats left. The
31
+ * UI decides where to *draw* a join control (see `isTicketSoldOut`) but never
32
+ * pre-judges the answer — a refusal comes back as the server's own message.
33
+ * 2. **Idempotence.** Re-submitting a join returns the EXISTING place, with its
34
+ * ORIGINAL quantity, and a 201. That is a success, not a duplicate error, and
35
+ * callers must present it as "you're on the list".
36
+ * 3. **Position.** `position` is 1-based while queueing and `null` once the row
37
+ * leaves the queue — which includes holding an open offer. An offer is not a
38
+ * queue place; never render a position for one.
39
+ */
40
+
41
+ /** The lifecycle, restated (Forge cannot import from the API). */
42
+ export type WaitlistStatus = "waiting" | "notified" | "converted" | "left" | "expired";
43
+
44
+ /** What an offer looks like to the person holding it. Computed server-side. */
45
+ export type WaitlistOffer = {
46
+ /** The buyer may buy RIGHT NOW. */
47
+ offerOpen: boolean;
48
+ /** Whole seconds left when the server answered. `null` when there is no offer. */
49
+ secondsRemaining: number | null;
50
+ /** The instant the offer lapses — the single most important field here. */
51
+ claimExpiresAt: string | null;
52
+ };
53
+
54
+ export type WaitlistEntry = {
55
+ id: string;
56
+ eventId: string;
57
+ eventTicketId: string;
58
+ status: WaitlistStatus | string;
59
+ quantity: number;
60
+ /** 1-based while queueing; `null` once the row leaves the queue (offer, buy, exit). */
61
+ position: number | null;
62
+ createdAt: string;
63
+ offer: WaitlistOffer;
64
+ };
65
+
66
+ export type JoinWaitlistInput = {
67
+ eventTicketId: string;
68
+ /**
69
+ * Required for an anonymous join, IGNORED for a signed-in one — the server
70
+ * takes a signed-in caller's address from their account. Callers should omit
71
+ * it entirely when signed in rather than send a value that cannot take effect.
72
+ */
73
+ email?: string;
74
+ firstName?: string;
75
+ lastName?: string;
76
+ /** 1–20. Bounded server-side; the form mirrors the bounds as input limits only. */
77
+ quantity?: number;
78
+ };
79
+
80
+ export type LeaveWaitlistResult = { id: string; status: string; leftAt: string | null };
81
+
82
+ /** Still in play — neither bought nor dropped out. Matches the API's `LIVE_WAITLIST_STATUSES`. */
83
+ export const LIVE_WAITLIST_STATUSES: WaitlistStatus[] = ["waiting", "notified"];
84
+
85
+ export const isLiveWaitlistEntry = (entry: Pick<WaitlistEntry, "status">): boolean =>
86
+ (LIVE_WAITLIST_STATUSES as string[]).includes(entry.status);
87
+
88
+ // ── Where a join control may be DRAWN ────────────────────────────────────────
89
+
90
+ /**
91
+ * Seats a buyer can still take, by the same arithmetic the purchase UI uses.
92
+ *
93
+ * Deliberately ignores `held` (units inside somebody else's live checkout) even
94
+ * though the server's waitlist rule subtracts it. The difference only ever makes
95
+ * this stricter: `quantity - sold <= 0` implies `quantity - sold - held <= 0`,
96
+ * so a tier this calls sold out is always one the server will accept a join for.
97
+ * Including `held` would do the opposite — offer a waitlist beside a "+" button
98
+ * the buyer can still press, on a tier that unfreezes minutes later.
99
+ */
100
+ export const ticketSeatsRemaining = (ticket: Pick<ITicket, "quantity" | "sold">): number =>
101
+ Math.max(0, (ticket.quantity ?? 0) - (ticket.sold ?? 0));
102
+
103
+ /**
104
+ * Should a waitlist join control be OFFERED for this tier?
105
+ *
106
+ * Presentation only. The server decides whether a join succeeds; this decides
107
+ * where it is worth asking, and is intentionally a subset of what the server
108
+ * would accept (see `ticketSeatsRemaining`, and the expiry note below).
109
+ *
110
+ * A tier whose sale window has closed is excluded: queueing for something that
111
+ * can no longer be sold means waiting for an email that will never arrive.
112
+ */
113
+ export const isTicketSoldOut = (
114
+ ticket: Pick<ITicket, "quantity" | "sold" | "expiresAt" | "archivedAt">,
115
+ now: Date = new Date(),
116
+ ): boolean => {
117
+ if (ticket.archivedAt) return false;
118
+ if (ticket.expiresAt && new Date(ticket.expiresAt) <= now) return false;
119
+ return ticketSeatsRemaining(ticket) === 0;
120
+ };
121
+
122
+ /** Events whose waitlist can still take a sign-up. Mirrors the API's joinable set. */
123
+ const JOINABLE_EVENT_STATUSES = ["scheduled", "on_sale", "sold_out"];
124
+
125
+ export const isEventWaitlistOpen = (event: Pick<IEvent, "status">): boolean =>
126
+ JOINABLE_EVENT_STATUSES.includes(event.status);
127
+
128
+ // ── The anonymous joiner's capability, remembered locally ────────────────────
129
+
130
+ /**
131
+ * An anonymous entry is addressed by its id alone — an unguessable UUID, the
132
+ * same shape as an unsubscribe link. The durable copy is the email the server
133
+ * sends; this is a convenience so the browser that joined can show the place
134
+ * back on the next visit, and it is the ONLY reason a local store exists here.
135
+ *
136
+ * Signed-in callers never need it: their entries come from the list endpoint.
137
+ */
138
+ const STORAGE_KEY = "tn.waitlist.entries";
139
+
140
+ export type RememberedWaitlistEntry = { eventId: string; entryId: string };
141
+
142
+ const readRemembered = (): RememberedWaitlistEntry[] => {
143
+ if (typeof window === "undefined") return [];
144
+ try {
145
+ const raw = window.localStorage.getItem(STORAGE_KEY);
146
+ if (!raw) return [];
147
+ const parsed: unknown = JSON.parse(raw);
148
+ if (!Array.isArray(parsed)) return [];
149
+ return parsed.filter(
150
+ (item): item is RememberedWaitlistEntry =>
151
+ !!item &&
152
+ typeof (item as RememberedWaitlistEntry).eventId === "string" &&
153
+ typeof (item as RememberedWaitlistEntry).entryId === "string",
154
+ );
155
+ } catch {
156
+ return [];
157
+ }
158
+ };
159
+
160
+ /**
161
+ * Cached so `useSyncExternalStore` gets a STABLE snapshot — returning a fresh
162
+ * array from every read would re-render forever. Invalidated on every write.
163
+ */
164
+ let cache: RememberedWaitlistEntry[] | null = null;
165
+ const listeners = new Set<() => void>();
166
+
167
+ const snapshot = (): RememberedWaitlistEntry[] => {
168
+ if (!cache) cache = readRemembered();
169
+ return cache;
170
+ };
171
+
172
+ /** The server-rendered pass has no browser store; it must be empty and stable. */
173
+ const EMPTY: RememberedWaitlistEntry[] = [];
174
+ const serverSnapshot = (): RememberedWaitlistEntry[] => EMPTY;
175
+
176
+ const subscribe = (listener: () => void) => {
177
+ listeners.add(listener);
178
+ return () => {
179
+ listeners.delete(listener);
180
+ };
181
+ };
182
+
183
+ const writeRemembered = (entries: RememberedWaitlistEntry[]) => {
184
+ cache = entries;
185
+ if (typeof window !== "undefined") {
186
+ try {
187
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
188
+ } catch {
189
+ /* private mode / quota — the email is still the durable copy. */
190
+ }
191
+ }
192
+ listeners.forEach((listener) => listener());
193
+ };
194
+
195
+ /** Entry ids this browser joined with, optionally narrowed to one event. */
196
+ export const getRememberedWaitlistEntries = (eventId?: string): RememberedWaitlistEntry[] => {
197
+ const all = snapshot();
198
+ return eventId ? all.filter((entry) => entry.eventId === eventId) : all;
199
+ };
200
+
201
+ export const rememberWaitlistEntry = (entry: RememberedWaitlistEntry) => {
202
+ const all = snapshot();
203
+ if (all.some((item) => item.entryId === entry.entryId)) return;
204
+ writeRemembered([...all, entry]);
205
+ };
206
+
207
+ export const forgetWaitlistEntry = (entryId: string) => {
208
+ const all = snapshot();
209
+ if (!all.some((item) => item.entryId === entryId)) return;
210
+ writeRemembered(all.filter((item) => item.entryId !== entryId));
211
+ };
212
+
213
+ /**
214
+ * The remembered ids, as a REACTIVE read.
215
+ *
216
+ * An anonymous joiner has no list endpoint to invalidate, so the local store is
217
+ * the only thing that changes when they join — a plain read would leave their
218
+ * new place invisible until a reload.
219
+ */
220
+ export function useRememberedWaitlistEntries(eventId?: string): RememberedWaitlistEntry[] {
221
+ const all = useSyncExternalStore(subscribe, snapshot, serverSnapshot);
222
+ return useMemo(() => (eventId ? all.filter((entry) => entry.eventId === eventId) : all), [all, eventId]);
223
+ }
224
+
225
+ // ── Hooks ────────────────────────────────────────────────────────────────────
226
+
227
+ const waitlistKey = (eventId?: string) => ["event-waitlist", eventId] as const;
228
+
229
+ /**
230
+ * Join the queue for a sold-out tier.
231
+ *
232
+ * Anonymous is allowed and is the common case. The returned entry id is
233
+ * remembered locally so this browser can read its place back; a signed-in
234
+ * caller gets the same row from the list endpoint regardless.
235
+ *
236
+ * Re-joining is not an error — the server hands back the existing place with
237
+ * its original quantity, so callers should render the result, not a warning.
238
+ */
239
+ export function useJoinEventWaitlist(eventId?: string) {
240
+ const { client, profileId } = useForge();
241
+ const queryClient = useQueryClient();
242
+
243
+ return useMutation<WaitlistEntry, unknown, JoinWaitlistInput>({
244
+ mutationFn: async (input) => {
245
+ const res = await client.post(`/public/events/${eventId}/waitlist`, {
246
+ profileId,
247
+ eventTicketId: input.eventTicketId,
248
+ // Sent only when there is one — the server ignores it for a signed-in
249
+ // caller, and an empty string would fail email validation.
250
+ ...(input.email ? { email: input.email } : {}),
251
+ ...(input.firstName ? { firstName: input.firstName } : {}),
252
+ ...(input.lastName ? { lastName: input.lastName } : {}),
253
+ quantity: input.quantity ?? 1,
254
+ });
255
+ return res.data;
256
+ },
257
+ onSuccess: (entry) => {
258
+ rememberWaitlistEntry({ eventId: entry.eventId, entryId: entry.id });
259
+ void queryClient.invalidateQueries({ queryKey: ["event-waitlist"] });
260
+ },
261
+ });
262
+ }
263
+
264
+ /**
265
+ * The signed-in buyer's own live entries for one event.
266
+ *
267
+ * `accountId` gates the query on being signed in and keys the cache; it is never
268
+ * sent. The server refuses an anonymous caller outright rather than returning an
269
+ * empty list, because "you are on no waitlist" is a different and wrong answer.
270
+ */
271
+ export function useMyEventWaitlist(eventId?: string, accountId?: string) {
272
+ const { client, profileId } = useForge();
273
+
274
+ return useQuery<WaitlistEntry[]>({
275
+ queryKey: [...waitlistKey(eventId), "mine", accountId, profileId],
276
+ queryFn: async () => {
277
+ const res = await client.get(`/public/events/${eventId}/waitlist`, { params: { profileId } });
278
+ return res.data;
279
+ },
280
+ enabled: !!eventId && !!accountId && !!profileId && !!client,
281
+ });
282
+ }
283
+
284
+ /** One entry's place and offer state, addressed by the id that IS its capability. */
285
+ export function useEventWaitlistEntry(eventId?: string, entryId?: string) {
286
+ const { client, profileId } = useForge();
287
+
288
+ return useQuery<WaitlistEntry>({
289
+ queryKey: [...waitlistKey(eventId), "entry", entryId, profileId],
290
+ queryFn: async () => {
291
+ const res = await client.get(`/public/events/${eventId}/waitlist/${entryId}`, { params: { profileId } });
292
+ return res.data;
293
+ },
294
+ enabled: !!eventId && !!entryId && !!profileId && !!client,
295
+ });
296
+ }
297
+
298
+ /**
299
+ * Leave the queue. Terminal, but not a ban — the same person may rejoin.
300
+ *
301
+ * Leaving twice, or leaving after converting, is a 400 by design: the server's
302
+ * conditional update IS the state machine, and a silent success would tell the
303
+ * buyer a place was removed that no longer existed.
304
+ */
305
+ export function useLeaveEventWaitlist(eventId?: string) {
306
+ const { client, profileId } = useForge();
307
+ const queryClient = useQueryClient();
308
+
309
+ return useMutation<LeaveWaitlistResult, unknown, { entryId: string }>({
310
+ mutationFn: async ({ entryId }) => {
311
+ const res = await client.delete(`/public/events/${eventId}/waitlist/${entryId}`, { params: { profileId } });
312
+ return res.data;
313
+ },
314
+ onSuccess: (result) => {
315
+ forgetWaitlistEntry(result.id);
316
+ void queryClient.invalidateQueries({ queryKey: ["event-waitlist"] });
317
+ },
318
+ });
319
+ }
320
+
321
+ /**
322
+ * Every place this visitor holds on ONE event, signed in or not.
323
+ *
324
+ * Two sources, merged and de-duplicated by entry id:
325
+ * - the list endpoint, when signed in (authoritative, cross-device);
326
+ * - this browser's remembered capability ids, which is the only way an
327
+ * anonymous joiner can be shown their own place.
328
+ *
329
+ * A signed-in visitor who joined anonymously earlier in the same browser sees
330
+ * both, which is exactly right: the anonymous entry is bound to no account and
331
+ * the list endpoint cannot know about it.
332
+ */
333
+ export function useEventWaitlistPlaces(eventId?: string, accountId?: string) {
334
+ const { client, profileId } = useForge();
335
+ const mine = useMyEventWaitlist(eventId, accountId);
336
+ const remembered = useRememberedWaitlistEntries(eventId);
337
+
338
+ const entryQueries = useQueries({
339
+ queries: remembered.map((item) => ({
340
+ queryKey: [...waitlistKey(eventId), "entry", item.entryId, profileId],
341
+ queryFn: async () => {
342
+ const res = await client.get(`/public/events/${eventId}/waitlist/${item.entryId}`, { params: { profileId } });
343
+ return res.data as WaitlistEntry;
344
+ },
345
+ enabled: !!eventId && !!profileId && !!client,
346
+ // A remembered id can 404 (another tenant's site, a purged row) — that is
347
+ // an answer, not a fault worth retrying.
348
+ retry: false,
349
+ })),
350
+ });
351
+
352
+ // `useQueries` returns a new array identity every render, so the memo is
353
+ // keyed on when its members last changed rather than on the array itself.
354
+ const entryStamp = entryQueries.map((query) => query.dataUpdatedAt).join(",");
355
+ const entries = useMemo(() => {
356
+ const byId = new Map<string, WaitlistEntry>();
357
+ for (const entry of mine.data ?? []) byId.set(entry.id, entry);
358
+ for (const query of entryQueries) {
359
+ const entry = query.data;
360
+ if (entry && !byId.has(entry.id)) byId.set(entry.id, entry);
361
+ }
362
+ return [...byId.values()];
363
+ // eslint-disable-next-line react-hooks/exhaustive-deps
364
+ }, [mine.data, entryStamp]);
365
+
366
+ return {
367
+ entries,
368
+ /** Only the LIVE ones — what "am I on the waitlist?" means to a buyer. */
369
+ liveEntries: useMemo(() => entries.filter(isLiveWaitlistEntry), [entries]),
370
+ isLoading: mine.isLoading || entryQueries.some((query) => query.isLoading),
371
+ };
372
+ }
373
+
374
+ export type WaitlistPlace = WaitlistEntry & {
375
+ /** The event this place is on, when it is one of the visitor's upcoming events. */
376
+ event?: IEvent;
377
+ /** The tier queued for, when the event payload still lists it. */
378
+ ticket?: ITicket;
379
+ };
380
+
381
+ /**
382
+ * Every live place the SIGNED-IN buyer holds, across upcoming events.
383
+ *
384
+ * There is no cross-event "my waitlist entries" endpoint, so this fans out the
385
+ * per-event list over the events the artist is currently showing — which is
386
+ * also the only set where a place still means anything, since a queue for a
387
+ * finished show can never be called.
388
+ *
389
+ * The fan-out is the cost of the missing endpoint and is bounded by that
390
+ * listing (20 events today). It is not used on the event page, where a single
391
+ * per-event read answers the same question.
392
+ */
393
+ export function useMyWaitlistPlaces(accountId?: string) {
394
+ const { client, profileId } = useForge();
395
+ const { data: events, isLoading: eventsLoading } = useEvents();
396
+
397
+ const eventList = useMemo(() => events ?? [], [events]);
398
+
399
+ const listQueries = useQueries({
400
+ queries: eventList.map((event) => ({
401
+ queryKey: ["event-waitlist", event.id, "mine", accountId, profileId],
402
+ queryFn: async () => {
403
+ const res = await client.get(`/public/events/${event.id}/waitlist`, { params: { profileId } });
404
+ return res.data as WaitlistEntry[];
405
+ },
406
+ enabled: !!accountId && !!profileId && !!client,
407
+ })),
408
+ });
409
+
410
+ const listStamp = listQueries.map((query) => query.dataUpdatedAt).join(",");
411
+ const places = useMemo(() => {
412
+ const out: WaitlistPlace[] = [];
413
+ listQueries.forEach((query, index) => {
414
+ const event = eventList[index];
415
+ for (const entry of query.data ?? []) {
416
+ if (!isLiveWaitlistEntry(entry)) continue;
417
+ out.push({ ...entry, event, ticket: event?.tickets?.find((t) => t.id === entry.eventTicketId) });
418
+ }
419
+ });
420
+ // An open offer is the only thing here with a deadline, so it sorts first.
421
+ return out.sort((a, b) => Number(b.offer.offerOpen) - Number(a.offer.offerOpen));
422
+ // eslint-disable-next-line react-hooks/exhaustive-deps
423
+ }, [eventList, listStamp]);
424
+
425
+ return {
426
+ places,
427
+ isLoading: eventsLoading || listQueries.some((query) => query.isLoading),
428
+ };
429
+ }
@@ -1,4 +1,4 @@
1
- import type { IEvent } from "../../types/models";
1
+ import type { AppliedDiscountCoupon, IEvent } from "../../types/models";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
3
  import { useMutation, useQuery } from "@tanstack/react-query";
4
4
 
@@ -48,12 +48,26 @@ export type CreateEventOrderInput = {
48
48
  lastName?: string;
49
49
  questionnaire?: unknown;
50
50
  attributionRefId?: string;
51
+ /**
52
+ * The code the buyer typed. Tickets have no apply-coupon endpoint — this call
53
+ * IS where a code is redeemed, so a refused one fails the whole request with
54
+ * the reason as its message and no order is created.
55
+ */
56
+ couponCode?: string;
51
57
  };
52
58
 
53
59
  export type CreateEventOrderResult = {
54
60
  orderId: string;
55
61
  isFreeCheckout?: boolean;
62
+ /** NET of any discount. */
56
63
  totalAmount?: number;
64
+ // Additive (S.3) — present on every response, discounted or not.
65
+ /** GROSS, before any discount. */
66
+ subTotal?: number;
67
+ discountAmount?: number;
68
+ couponId?: string | null;
69
+ /** Every discount that applied, entered OR automatic. */
70
+ appliedCoupons?: AppliedDiscountCoupon[];
57
71
  };
58
72
 
59
73
  /** Create an event ticket order (the step before start-payment). */
@@ -0,0 +1,211 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import { useForge } from "../../provider/ForgeProvider";
3
+ import type { PaginatedData } from "../../types/models";
4
+
5
+ /**
6
+ * The signed-in buyer's own coaching sessions, moving one and cancelling one
7
+ * (S.6 / bookings § 1.4).
8
+ *
9
+ * Session buyers had NO purchase surface at all before this — a booking's only
10
+ * artefacts were a confirmation email and a one-shot finalise page. Lives in
11
+ * Forge rather than in either app because both rendering surfaces need exactly
12
+ * this, and a second copy is how the two drift (as with `useMyTickets`).
13
+ *
14
+ * ## The endpoints
15
+ *
16
+ * ```
17
+ * GET /public/coaching/bookings/mine → PaginatedData<MyBooking> (401 if anonymous)
18
+ * POST /public/coaching/bookings/:bookingId/reschedule { profileId, slotId } → RescheduleResult
19
+ * POST /public/coaching/bookings/:bookingId/cancel { profileId } → CancelBookingResult
20
+ * ```
21
+ *
22
+ * The target slot comes from `useCoachingAvailability(coachingProductId, …)`,
23
+ * which already excludes slots that are taken and hours the coach is busy on
24
+ * ANY of their services. It is the same grid the original purchase used.
25
+ *
26
+ * ## What the server owns and this file does not mirror
27
+ *
28
+ * - **Identity.** The buyer is resolved from the SESSION and is never a
29
+ * parameter; `accountId` here only gates the query on being signed in and
30
+ * keys the cache. Not-yours is answered 404, never 403.
31
+ * - **Whether a session may be moved.** `canReschedule` / `rescheduleReason`
32
+ * come back on every row. Re-deriving "has it started yet" in the UI would
33
+ * give two answers to one question, and the wrong one is the one the buyer
34
+ * acts on. Draw the control from the flag; surface the refusal verbatim.
35
+ * - **Which slot is acceptable.** Same product, not the current slot, not in
36
+ * the past, and the coach actually free — the last of which is decided by a
37
+ * database EXCLUDE constraint, so two buyers racing for one hour is settled
38
+ * there and comes back as a 409 with the server's wording.
39
+ *
40
+ * ## Cancellation
41
+ *
42
+ * `cancellation` is present on every row and comes from the POLICY SNAPSHOT
43
+ * taken on the booking at purchase — never the coach's current setting, which
44
+ * is the whole reason the snapshot exists. `canCancel` is the only thing that
45
+ * decides whether a cancel control is drawn.
46
+ *
47
+ * The cancel itself is idempotent server-side: a single conditional UPDATE
48
+ * claims the cancellation before any provider call, so a double-clicked cancel
49
+ * refunds ONCE. `outcome` says what actually happened to the money
50
+ * (`refunded`, `voided_free`, `manual_refund_required`, …) and is worth showing
51
+ * — "cancelled" alone leaves a buyer wondering where their payment went.
52
+ */
53
+
54
+ /** Why the buyer cannot cancel, when they cannot. Mirrors the shared evaluator. */
55
+ export type BookingCancellationReason = "no_policy" | "not_allowed" | "window_closed" | "event_started";
56
+
57
+ export type BookingCancellation = {
58
+ policy: "none" | "until" | "anytime" | null;
59
+ cutoffHours: number | null;
60
+ terms: string | null;
61
+ /** One sentence, already phrased for a buyer. Null when no policy was set. */
62
+ description: string | null;
63
+ canCancel: boolean;
64
+ reason: BookingCancellationReason | null;
65
+ /** The instant the window shuts. */
66
+ deadline: string | null;
67
+ };
68
+
69
+ /**
70
+ * Why the buyer cannot move this session, when they cannot.
71
+ *
72
+ * `window_closed` is the operator's NOTICE PERIOD, shared with the cancel path
73
+ * so "up to 24 hours before" means one thing across the pillar.
74
+ */
75
+ export type RescheduleRefusal = "not_allowed" | "session_started" | "window_closed";
76
+
77
+ export type MyBooking = {
78
+ id: string;
79
+ /** `confirmed` or `canceled` — holds and lapsed holds are never listed. */
80
+ status: string;
81
+ email: string;
82
+ firstName: string | null;
83
+ lastName: string | null;
84
+ /** Already a number (or null for a free session), not a decimal string. */
85
+ totalAmount: number | null;
86
+ totalAmountInChargedCurrency: string | number | null;
87
+ currency: string | null;
88
+ chargedCurrency: string | null;
89
+ refundState: string | null;
90
+ refundedAmountCents: string | number | null;
91
+ /** Set once the BUYER cancelled it themselves (as opposed to the operator). */
92
+ selfCancelledAt: string | null;
93
+ /** What became of the money: `refunded`, `voided_free`, `manual_refund_required`, … */
94
+ cancellationOutcome: string | null;
95
+ createdAt: string;
96
+ /** The slot the booking currently points at — what a reschedule moves. */
97
+ coachingBookingSlotId: string;
98
+ /**
99
+ * The authoritative session times: the SLOT's, not the booking's denormalised
100
+ * copy. After a move the two agree; on a row written before `start_time`
101
+ * existed only the slot has an answer at all.
102
+ */
103
+ sessionStartTime: string;
104
+ sessionEndTime: string;
105
+ coachingProductId: string;
106
+ coachingProductSlug: string | null;
107
+ coachingProductTitle: string;
108
+ coachingProductTimezone: string | null;
109
+ coachingProductDurationMinutes: number | null;
110
+ cancellation: BookingCancellation;
111
+ /** The server's answer. Never recomputed here. */
112
+ canReschedule: boolean;
113
+ rescheduleReason: RescheduleRefusal | null;
114
+ };
115
+
116
+ export type CancelBookingResult = {
117
+ bookingId: string;
118
+ /** True when money actually went back; false for a free or already-refunded session. */
119
+ refunded: boolean;
120
+ outcome: string;
121
+ };
122
+
123
+ export type RescheduleBookingResult = {
124
+ bookingId: string;
125
+ coachingProductId: string;
126
+ previousSlotId: string;
127
+ slotId: string;
128
+ sessionStartTime: string;
129
+ sessionEndTime: string;
130
+ timezone: string | null;
131
+ };
132
+
133
+ /** Sessions that have not happened yet, by the server's own session time. */
134
+ export const isUpcomingBooking = (booking: Pick<MyBooking, "sessionStartTime">, now: Date = new Date()): boolean =>
135
+ new Date(booking.sessionStartTime).getTime() > now.getTime();
136
+
137
+ /**
138
+ * The buyer's sessions with this artist, newest session first.
139
+ *
140
+ * `accountId` gates the query on being signed in and keys the cache; it is
141
+ * never sent. The server refuses an anonymous caller with a 401 rather than an
142
+ * empty list, because "you have no sessions" is a different and wrong answer.
143
+ */
144
+ export function useMyBookings(accountId?: string, page = 1, limit = 20) {
145
+ const { client, profileId } = useForge();
146
+
147
+ return useQuery<PaginatedData<MyBooking>>({
148
+ queryKey: ["my-bookings", accountId, profileId, page, limit],
149
+ queryFn: async () => {
150
+ const res = await client.get("/public/coaching/bookings/mine", {
151
+ params: { profileId, page, limit },
152
+ });
153
+ return res.data;
154
+ },
155
+ enabled: !!accountId && !!profileId && !!client,
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Move one of the buyer's own sessions to another slot.
161
+ *
162
+ * Invalidates the list AND the availability grid on success: the old hour is
163
+ * free again and the new one is not, so a stale grid would offer a time the
164
+ * server has just refused. Re-reading is cheaper than mirroring the rules.
165
+ */
166
+ export function useRescheduleBooking() {
167
+ const { client, profileId } = useForge();
168
+ const queryClient = useQueryClient();
169
+
170
+ return useMutation<RescheduleBookingResult, unknown, { bookingId: string; slotId: string }>({
171
+ mutationFn: async ({ bookingId, slotId }) => {
172
+ const res = await client.post(`/public/coaching/bookings/${bookingId}/reschedule`, {
173
+ profileId,
174
+ slotId,
175
+ });
176
+ return res.data;
177
+ },
178
+ onSuccess: () => {
179
+ void queryClient.invalidateQueries({ queryKey: ["my-bookings"] });
180
+ void queryClient.invalidateQueries({ queryKey: ["coaching-availability"] });
181
+ },
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Cancel one of the buyer's own sessions, inside the policy they booked under.
187
+ *
188
+ * Routed server-side through the same refund service an operator-issued refund
189
+ * uses, so the ledger, the tax reversal and the freeing of the coach's hour are
190
+ * identical — only `initiated_by` differs. Eligibility is the server's
191
+ * (`cancellation.canCancel`); this hook never pre-judges it, and a refusal comes
192
+ * back with the server's own wording for the rule that stopped it.
193
+ *
194
+ * The list and the availability grid are both invalidated: the row's state has
195
+ * changed and the hour is bookable again.
196
+ */
197
+ export function useCancelMyBooking() {
198
+ const { client, profileId } = useForge();
199
+ const queryClient = useQueryClient();
200
+
201
+ return useMutation<CancelBookingResult, unknown, { bookingId: string }>({
202
+ mutationFn: async ({ bookingId }) => {
203
+ const res = await client.post(`/public/coaching/bookings/${bookingId}/cancel`, { profileId });
204
+ return res.data;
205
+ },
206
+ onSuccess: () => {
207
+ void queryClient.invalidateQueries({ queryKey: ["my-bookings"] });
208
+ void queryClient.invalidateQueries({ queryKey: ["coaching-availability"] });
209
+ },
210
+ });
211
+ }