@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.
- package/package.json +1 -1
- package/src/data/queries/_tests/eventWaitlist.spec.ts +122 -0
- package/src/data/queries/_tests/passTransfers.spec.ts +89 -0
- package/src/data/queries/_tests/walletPass.spec.ts +159 -0
- package/src/data/queries/useCheckouts.ts +84 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -3
- package/src/data/queries/useCourses.ts +42 -1
- package/src/data/queries/useEventWaitlist.ts +429 -0
- package/src/data/queries/useEvents.ts +15 -1
- package/src/data/queries/useMyBookings.ts +211 -0
- package/src/data/queries/useMyTickets.ts +154 -0
- package/src/data/queries/usePassTransfers.ts +318 -0
- package/src/data/queries/usePaymentFlow.ts +12 -0
- package/src/data/queries/useWalletPass.ts +236 -0
- package/src/data/queries/useWebsite.ts +6 -0
- package/src/index.ts +14 -0
- package/src/server/_tests/siteBootstrap.spec.ts +131 -0
- package/src/server/index.ts +122 -6
- package/src/types/diagnostics.ts +49 -0
- package/src/types/models.ts +66 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
- package/src/ui/headless/checkout/useCheckout.ts +156 -8
- package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
- package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
- package/src/ui/headless/coupon/useCouponField.ts +164 -0
- package/src/ui/headless/course/useCourseCheckout.ts +113 -18
- package/src/ui/headless/event/useEventCheckout.ts +53 -2
- package/src/ui/headless/index.ts +15 -0
- package/src/ui/index.ts +26 -0
- package/src/ui/shell/PoweredBy.tsx +62 -0
- package/src/ui/shell/PreviewDiagnostics.tsx +80 -0
- package/src/ui/shell/TribeNestApp.tsx +39 -1
- package/src/ui/shell/diagnosticsGating.spec.ts +102 -0
- package/src/ui/shell/diagnosticsGating.ts +90 -0
- package/src/ui/shell/shellGating.spec.ts +60 -1
- package/src/ui/shell/shellGating.ts +47 -0
- package/src/ui/styled/AccountDashboard.tsx +598 -1
- package/src/ui/styled/AddToCalendar.tsx +104 -0
- package/src/ui/styled/Checkout.tsx +45 -14
- package/src/ui/styled/CoachingBooking.tsx +28 -8
- package/src/ui/styled/CoachingConfirmation.tsx +12 -0
- package/src/ui/styled/CourseCheckout.tsx +49 -18
- package/src/ui/styled/DiscountCode.tsx +206 -0
- package/src/ui/styled/EventConfirmation.tsx +68 -22
- package/src/ui/styled/EventDetail.tsx +24 -5
- package/src/ui/styled/EventTickets.tsx +49 -5
- package/src/ui/styled/EventWaitlist.tsx +448 -0
- package/src/ui/styled/TicketTransfer.tsx +393 -0
- package/src/ui/styled/WalletPassButtons.tsx +208 -0
- package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
- package/src/ui/styled/_tests/WalletPassButtons.spec.tsx +223 -0
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
- 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
|
+
}
|