@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,448 @@
1
+ import { useEffect, useState, type CSSProperties } from "react";
2
+ import { BellRing, Clock, Users } from "lucide-react";
3
+ import type { IEvent, ITicket } from "../../types/models";
4
+ import { usePublicAuth } from "../../contexts/PublicAuthContext";
5
+ import { useEvent } from "../../data/queries/useEvents";
6
+ import {
7
+ isEventWaitlistOpen,
8
+ isTicketSoldOut,
9
+ useEventWaitlistPlaces,
10
+ useJoinEventWaitlist,
11
+ useLeaveEventWaitlist,
12
+ type WaitlistEntry,
13
+ } from "../../data/queries/useEventWaitlist";
14
+ import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
15
+ import { Loading } from "./Loading";
16
+
17
+ /**
18
+ * Events 2.6 — the buyer-facing waitlist, on a sold-out event.
19
+ *
20
+ * Two jobs, and they are deliberately independent:
21
+ *
22
+ * JOIN is offered only where the tier has nothing left to sell. A queue for
23
+ * something you could simply buy costs a sale — the joiner waits for a
24
+ * release that never happens, because nothing froze in the first place.
25
+ * PLACE is shown for every live entry, sold out or not. A seat freeing up is
26
+ * exactly what makes the tier buyable again, so the moment an offer
27
+ * matters is the moment the join form disappears.
28
+ *
29
+ * Nothing here re-derives eligibility, position, or the offer window. Those come
30
+ * off the server on every read; mirroring them would give two answers to one
31
+ * question and the wrong one is what the buyer would act on. Same reasoning as
32
+ * the ticket-cancellation policy in `AccountDashboard`.
33
+ */
34
+
35
+ export interface EventWaitlistProps {
36
+ /**
37
+ * The event, already loaded. Preferred: the waitlist API addresses events by
38
+ * UUID only, so a slug alone costs an extra round trip.
39
+ */
40
+ event?: IEvent;
41
+ /** Slug or id, when the host has not already loaded the event. */
42
+ slug?: string;
43
+ /** Section heading. */
44
+ title?: string;
45
+ className?: string;
46
+ style?: CSSProperties;
47
+ }
48
+
49
+ const alpha = (hex: string, a: number) => hex + Math.round(a * 255).toString(16).padStart(2, "0");
50
+
51
+ export function EventWaitlist({ event: initialEvent, slug, title = "Waitlist", className, style }: EventWaitlistProps) {
52
+ const t = useThemeTokens();
53
+ const { user } = usePublicAuth();
54
+ const { data: fetched } = useEvent(initialEvent ? undefined : slug);
55
+ const event = initialEvent ?? fetched;
56
+
57
+ const { liveEntries: live, isLoading } = useEventWaitlistPlaces(event?.id, user?.id);
58
+
59
+ if (!event) return null;
60
+
61
+ const soldOutTiers = isEventWaitlistOpen(event)
62
+ ? event.tickets.filter((ticket) => isTicketSoldOut(ticket) && !live.some((e) => e.eventTicketId === ticket.id))
63
+ : [];
64
+
65
+ // Nothing to queue for and nothing queued — say nothing at all.
66
+ if (soldOutTiers.length === 0 && live.length === 0) return null;
67
+
68
+ return (
69
+ <section
70
+ className={className}
71
+ style={{
72
+ border: `1px solid ${alpha(t.primary, 0.25)}`,
73
+ borderRadius: t.cornerRadius,
74
+ background: t.surface,
75
+ color: t.text,
76
+ padding: 20,
77
+ ...style,
78
+ }}
79
+ >
80
+ <h2
81
+ style={{
82
+ display: "flex",
83
+ alignItems: "center",
84
+ gap: 8,
85
+ fontSize: 18,
86
+ fontWeight: 700,
87
+ margin: "0 0 4px",
88
+ fontFamily: t.headingFontFamily,
89
+ }}
90
+ >
91
+ <Users size={18} style={{ color: t.primary }} />
92
+ {title}
93
+ </h2>
94
+ <p style={{ fontSize: 14, opacity: 0.75, margin: "0 0 16px" }}>
95
+ Sold out. Join the queue and we&apos;ll email you if a ticket is released.
96
+ </p>
97
+
98
+ {isLoading && live.length === 0 && soldOutTiers.length === 0 ? (
99
+ <Loading />
100
+ ) : (
101
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
102
+ {live.map((entry) => (
103
+ <WaitlistPlaceCard
104
+ key={entry.id}
105
+ entry={entry}
106
+ eventId={event.id}
107
+ ticket={event.tickets.find((ticket) => ticket.id === entry.eventTicketId)}
108
+ t={t}
109
+ />
110
+ ))}
111
+ {soldOutTiers.map((ticket) => (
112
+ <JoinCard key={ticket.id} eventId={event.id} ticket={ticket} t={t} signedIn={!!user} />
113
+ ))}
114
+ </div>
115
+ )}
116
+ </section>
117
+ );
118
+ }
119
+
120
+ // ── A place already held ─────────────────────────────────────────────────────
121
+
122
+ /**
123
+ * One live entry: its place, or the offer that replaced it.
124
+ *
125
+ * `position` is `null` for a notified entry by design — an offer is not a queue
126
+ * place — so the two states are rendered as different things rather than as a
127
+ * position that happens to be missing.
128
+ */
129
+ function WaitlistPlaceCard({
130
+ entry,
131
+ eventId,
132
+ ticket,
133
+ t,
134
+ }: {
135
+ entry: WaitlistEntry;
136
+ eventId: string;
137
+ ticket?: ITicket;
138
+ t: ResolvedThemeTokens;
139
+ }) {
140
+ const leave = useLeaveEventWaitlist(eventId);
141
+ const [error, setError] = useState<string | null>(null);
142
+ const offerOpen = entry.offer.offerOpen;
143
+
144
+ const onLeave = async () => {
145
+ setError(null);
146
+ try {
147
+ await leave.mutateAsync({ entryId: entry.id });
148
+ } catch (err) {
149
+ setError(readError(err, "We could not remove you from the waitlist. Please try again."));
150
+ }
151
+ };
152
+
153
+ return (
154
+ <div
155
+ style={{
156
+ border: `1px solid ${alpha(offerOpen ? t.primary : t.primary, offerOpen ? 0.6 : 0.2)}`,
157
+ borderRadius: t.cornerRadius,
158
+ padding: 16,
159
+ }}
160
+ >
161
+ <div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
162
+ <div>
163
+ <div style={{ fontWeight: 600 }}>{ticket?.title ?? "Ticket"}</div>
164
+ <div style={{ fontSize: 13, opacity: 0.7 }}>
165
+ {entry.quantity} {entry.quantity === 1 ? "ticket" : "tickets"} requested
166
+ </div>
167
+ </div>
168
+ {offerOpen ? (
169
+ <span style={{ display: "flex", alignItems: "center", gap: 6, fontWeight: 700, color: t.primary }}>
170
+ <BellRing size={16} />
171
+ Tickets available
172
+ </span>
173
+ ) : entry.position != null ? (
174
+ <span style={{ fontSize: 14, opacity: 0.85 }}>
175
+ Position <strong style={{ color: t.primary }}>#{entry.position}</strong>
176
+ </span>
177
+ ) : (
178
+ <span style={{ fontSize: 14, opacity: 0.85 }}>You&apos;re on the list</span>
179
+ )}
180
+ </div>
181
+
182
+ {offerOpen && <OfferDeadline entry={entry} t={t} />}
183
+
184
+ {error && <p style={{ color: "#ef4444", fontSize: 13, marginTop: 12 }}>{error}</p>}
185
+
186
+ <button
187
+ type="button"
188
+ onClick={() => void onLeave()}
189
+ disabled={leave.isPending}
190
+ style={{
191
+ marginTop: 12,
192
+ padding: "8px 14px",
193
+ borderRadius: t.cornerRadius,
194
+ border: `1px solid ${alpha(t.primary, 0.4)}`,
195
+ background: "transparent",
196
+ color: t.text,
197
+ cursor: leave.isPending ? "wait" : "pointer",
198
+ fontWeight: 600,
199
+ }}
200
+ >
201
+ {leave.isPending ? "Leaving…" : "Leave waitlist"}
202
+ </button>
203
+ </div>
204
+ );
205
+ }
206
+
207
+ /**
208
+ * The deadline on an open offer — the whole point of being notified.
209
+ *
210
+ * The absolute instant is the authoritative statement and is always shown; the
211
+ * countdown beside it starts from the server's own `secondsRemaining` and ticks
212
+ * locally, so a slow clock can shorten what the buyer sees but never lengthen it.
213
+ */
214
+ function OfferDeadline({ entry, t }: { entry: WaitlistEntry; t: ResolvedThemeTokens }) {
215
+ const [remaining, setRemaining] = useState(entry.offer.secondsRemaining ?? 0);
216
+
217
+ useEffect(() => {
218
+ setRemaining(entry.offer.secondsRemaining ?? 0);
219
+ }, [entry.offer.secondsRemaining]);
220
+
221
+ // One interval for the whole open window — restarted only when the offer
222
+ // crosses between "time left" and "no time left", not on every tick.
223
+ const ticking = remaining > 0;
224
+ useEffect(() => {
225
+ if (!ticking) return;
226
+ const id = setInterval(() => setRemaining((value) => Math.max(0, value - 1)), 1000);
227
+ return () => clearInterval(id);
228
+ }, [ticking]);
229
+
230
+ const deadline = entry.offer.claimExpiresAt
231
+ ? new Date(entry.offer.claimExpiresAt).toLocaleString(undefined, {
232
+ weekday: "short",
233
+ day: "numeric",
234
+ month: "short",
235
+ hour: "numeric",
236
+ minute: "2-digit",
237
+ })
238
+ : null;
239
+
240
+ return (
241
+ <div
242
+ style={{
243
+ marginTop: 12,
244
+ padding: "10px 12px",
245
+ borderRadius: t.cornerRadius,
246
+ background: alpha(t.primary, 0.08),
247
+ border: `1px solid ${alpha(t.primary, 0.3)}`,
248
+ fontSize: 14,
249
+ }}
250
+ >
251
+ <div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 600 }}>
252
+ <Clock size={14} style={{ color: t.primary }} />
253
+ {remaining > 0 ? `Buy within ${formatCountdown(remaining)}` : "This offer has closed"}
254
+ </div>
255
+ {deadline && (
256
+ <div style={{ opacity: 0.8, marginTop: 4 }}>
257
+ {remaining > 0 ? `Your tickets are held until ${deadline}.` : `Your offer expired at ${deadline}.`} Tickets are
258
+ not reserved — buy from the ticket options above to secure them.
259
+ </div>
260
+ )}
261
+ </div>
262
+ );
263
+ }
264
+
265
+ // ── Joining ──────────────────────────────────────────────────────────────────
266
+
267
+ /**
268
+ * The join form for one sold-out tier.
269
+ *
270
+ * The email field appears only for an anonymous visitor: the server takes a
271
+ * signed-in caller's address from their account and ignores any sent, so asking
272
+ * for one would invite them to type an address that cannot take effect.
273
+ *
274
+ * A re-submission is NOT an error — the server returns the existing place with
275
+ * its original quantity, which is why the success copy says "you're on the list"
276
+ * rather than "joined", and why the requested quantity is read back from the
277
+ * response instead of from the form.
278
+ */
279
+ function JoinCard({
280
+ eventId,
281
+ ticket,
282
+ t,
283
+ signedIn,
284
+ }: {
285
+ eventId: string;
286
+ ticket: ITicket;
287
+ t: ResolvedThemeTokens;
288
+ signedIn: boolean;
289
+ }) {
290
+ const join = useJoinEventWaitlist(eventId);
291
+ const [open, setOpen] = useState(false);
292
+ const [email, setEmail] = useState("");
293
+ const [firstName, setFirstName] = useState("");
294
+ const [lastName, setLastName] = useState("");
295
+ const [quantity, setQuantity] = useState(1);
296
+ const [error, setError] = useState<string | null>(null);
297
+
298
+ const onSubmit = async (e: React.FormEvent) => {
299
+ e.preventDefault();
300
+ setError(null);
301
+ if (!signedIn && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
302
+ return setError("Please enter a valid email address.");
303
+ }
304
+ try {
305
+ await join.mutateAsync({
306
+ eventTicketId: ticket.id,
307
+ email: signedIn ? undefined : email,
308
+ firstName: firstName || undefined,
309
+ lastName: lastName || undefined,
310
+ quantity,
311
+ });
312
+ } catch (err) {
313
+ setError(readError(err, "We could not add you to the waitlist. Please try again."));
314
+ }
315
+ };
316
+
317
+ const input: CSSProperties = {
318
+ width: "100%",
319
+ padding: 10,
320
+ borderRadius: t.cornerRadius,
321
+ border: `1px solid ${alpha(t.primary, 0.3)}`,
322
+ background: "transparent",
323
+ color: t.text,
324
+ fontFamily: t.fontFamily,
325
+ };
326
+
327
+ return (
328
+ <div style={{ border: `1px solid ${alpha(t.primary, 0.2)}`, borderRadius: t.cornerRadius, padding: 16 }}>
329
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
330
+ <div>
331
+ <div style={{ fontWeight: 600 }}>{ticket.title}</div>
332
+ <div style={{ fontSize: 13, opacity: 0.7 }}>Sold out</div>
333
+ </div>
334
+ {!open && (
335
+ <button
336
+ type="button"
337
+ onClick={() => setOpen(true)}
338
+ style={{
339
+ padding: "8px 14px",
340
+ borderRadius: t.cornerRadius,
341
+ border: "none",
342
+ background: t.primary,
343
+ color: t.textPrimary,
344
+ fontWeight: 600,
345
+ cursor: "pointer",
346
+ }}
347
+ >
348
+ Join waitlist
349
+ </button>
350
+ )}
351
+ </div>
352
+
353
+ {open && (
354
+ <form onSubmit={onSubmit} style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 10 }}>
355
+ {!signedIn && (
356
+ <input
357
+ type="email"
358
+ placeholder="Email"
359
+ value={email}
360
+ onChange={(e) => setEmail(e.target.value)}
361
+ style={input}
362
+ aria-label="Email"
363
+ />
364
+ )}
365
+ <div style={{ display: "flex", gap: 8 }}>
366
+ <input
367
+ placeholder="First name (optional)"
368
+ value={firstName}
369
+ onChange={(e) => setFirstName(e.target.value)}
370
+ style={input}
371
+ aria-label="First name"
372
+ />
373
+ <input
374
+ placeholder="Last name (optional)"
375
+ value={lastName}
376
+ onChange={(e) => setLastName(e.target.value)}
377
+ style={input}
378
+ aria-label="Last name"
379
+ />
380
+ </div>
381
+ <label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 14 }}>
382
+ How many tickets?
383
+ <input
384
+ type="number"
385
+ min={1}
386
+ max={20}
387
+ value={quantity}
388
+ onChange={(e) => setQuantity(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
389
+ style={{ ...input, width: 80 }}
390
+ />
391
+ </label>
392
+ {error && <p style={{ color: "#ef4444", fontSize: 13, margin: 0 }}>{error}</p>}
393
+ <div style={{ display: "flex", gap: 8 }}>
394
+ <button
395
+ type="submit"
396
+ disabled={join.isPending}
397
+ style={{
398
+ padding: "8px 14px",
399
+ borderRadius: t.cornerRadius,
400
+ border: "none",
401
+ background: t.primary,
402
+ color: t.textPrimary,
403
+ fontWeight: 600,
404
+ cursor: join.isPending ? "wait" : "pointer",
405
+ }}
406
+ >
407
+ {join.isPending ? "Joining…" : "Join waitlist"}
408
+ </button>
409
+ <button
410
+ type="button"
411
+ onClick={() => setOpen(false)}
412
+ style={{
413
+ padding: "8px 14px",
414
+ borderRadius: t.cornerRadius,
415
+ border: `1px solid ${alpha(t.primary, 0.3)}`,
416
+ background: "transparent",
417
+ color: t.text,
418
+ cursor: "pointer",
419
+ }}
420
+ >
421
+ Cancel
422
+ </button>
423
+ </div>
424
+ <p style={{ fontSize: 12, opacity: 0.65, margin: 0 }}>
425
+ We&apos;ll only email you about tickets for this event.
426
+ </p>
427
+ </form>
428
+ )}
429
+ </div>
430
+ );
431
+ }
432
+
433
+ // ── Shared ───────────────────────────────────────────────────────────────────
434
+
435
+ /** Whole hours and minutes while there is time; seconds once it is close. */
436
+ export function formatCountdown(totalSeconds: number): string {
437
+ const hours = Math.floor(totalSeconds / 3600);
438
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
439
+ const seconds = totalSeconds % 60;
440
+ if (hours > 0) return `${hours}h ${minutes}m`;
441
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
442
+ return `${seconds}s`;
443
+ }
444
+
445
+ /** The server's own refusal, which is the only correct explanation to show. */
446
+ function readError(err: unknown, fallback: string): string {
447
+ return (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? fallback;
448
+ }