@tribe-nest/forge 3.4.0 → 3.11.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 (42) 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/useCoachingAvailability.ts +14 -3
  6. package/src/data/queries/useEventSeries.ts +42 -0
  7. package/src/data/queries/useEventWaitlist.ts +429 -0
  8. package/src/data/queries/useEvents.ts +10 -1
  9. package/src/data/queries/useMyBookings.ts +211 -0
  10. package/src/data/queries/useMyTickets.ts +154 -0
  11. package/src/data/queries/usePassTransfers.ts +318 -0
  12. package/src/data/queries/useSubscriptions.ts +53 -0
  13. package/src/data/queries/useWalletPass.ts +236 -0
  14. package/src/index.ts +9 -0
  15. package/src/server/_tests/siteBootstrap.spec.ts +131 -0
  16. package/src/server/index.ts +142 -6
  17. package/src/types/diagnostics.ts +49 -0
  18. package/src/types/models.ts +146 -0
  19. package/src/ui/format/bookingFee.ts +98 -0
  20. package/src/ui/headless/coaching/useCoachingBooking.ts +19 -0
  21. package/src/ui/headless/event/useEventCheckout.ts +61 -2
  22. package/src/ui/headless/membership/MembershipGate.tsx +7 -2
  23. package/src/ui/index.ts +29 -0
  24. package/src/ui/shell/PoweredBy.tsx +6 -4
  25. package/src/ui/shell/PreviewDiagnostics.tsx +80 -0
  26. package/src/ui/shell/TribeNestApp.tsx +24 -0
  27. package/src/ui/shell/diagnosticsGating.spec.ts +102 -0
  28. package/src/ui/shell/diagnosticsGating.ts +90 -0
  29. package/src/ui/shell/shellGating.spec.ts +40 -1
  30. package/src/ui/shell/shellGating.ts +33 -0
  31. package/src/ui/styled/AccountDashboard.tsx +643 -5
  32. package/src/ui/styled/CancellationTerms.tsx +70 -0
  33. package/src/ui/styled/CoachingBooking.tsx +10 -0
  34. package/src/ui/styled/CoachingDetail.tsx +4 -0
  35. package/src/ui/styled/EventDetail.tsx +18 -0
  36. package/src/ui/styled/EventSeriesDetail.tsx +222 -0
  37. package/src/ui/styled/EventTickets.tsx +58 -0
  38. package/src/ui/styled/EventWaitlist.tsx +448 -0
  39. package/src/ui/styled/TicketTransfer.tsx +393 -0
  40. package/src/ui/styled/WalletPassButtons.tsx +208 -0
  41. package/src/ui/styled/_tests/WalletPassButtons.spec.tsx +223 -0
  42. package/src/utils/membershipAccess.ts +182 -0
@@ -2,10 +2,24 @@ import { useEffect, useState } from "react";
2
2
  import {
3
3
  usePublicAuth,
4
4
  useUserOrders,
5
+ useMyTickets,
6
+ useCancelMyTicket,
7
+ myTicketPassIds,
8
+ type MyTicket,
9
+ useMyWaitlistPlaces,
10
+ useLeaveEventWaitlist,
11
+ type WaitlistPlace,
12
+ useMyBookings,
13
+ useRescheduleBooking,
14
+ useCancelMyBooking,
15
+ useCoachingAvailability,
16
+ type MyBooking,
5
17
  useSavedPosts,
6
18
  useNotificationPreferences,
7
19
  useUpdateNotificationPreference,
8
20
  useCancelMembership,
21
+ useMembershipAccess,
22
+ useOpenBillingPortal,
9
23
  useUpdateAccount,
10
24
  useChangePassword,
11
25
  useExportAccountData,
@@ -19,8 +33,25 @@ import { useThemeTokens } from "../theme/ForgeThemeProvider";
19
33
  import { useSiteConfig } from "../../data/queries/useWebsite";
20
34
  import { useAmountFormatter } from "../format/useFormatCurrency";
21
35
  import { Loading } from "./Loading";
36
+ import { formatCountdown } from "./EventWaitlist";
37
+ import { WalletPassButtons } from "./WalletPassButtons";
38
+ import { TicketTransferPanel } from "./TicketTransfer";
22
39
 
23
- export const ACCOUNT_TABS = ["membership", "orders", "saved", "account", "notifications"] as const;
40
+ export const ACCOUNT_TABS = [
41
+ "membership",
42
+ "orders",
43
+ "tickets",
44
+ // Next to tickets because it is the same question one step earlier: what am I
45
+ // holding for this artist's shows? (2.6)
46
+ "waitlist",
47
+ // Sessions bought from this artist (S.6 / bookings § 1.4). A booking is not an
48
+ // "order" — the Orders tab covers product orders only — so it needs its own
49
+ // tab or it is unreachable, which is what it was.
50
+ "bookings",
51
+ "saved",
52
+ "account",
53
+ "notifications",
54
+ ] as const;
24
55
  export type AccountTabKey = (typeof ACCOUNT_TABS)[number];
25
56
 
26
57
  export interface AccountDashboardProps {
@@ -175,6 +206,9 @@ export function AccountDashboard({
175
206
 
176
207
  {tab === "membership" && <MembershipTab ctx={ctx} />}
177
208
  {tab === "orders" && <OrdersTab ctx={ctx} />}
209
+ {tab === "tickets" && <TicketsTab ctx={ctx} />}
210
+ {tab === "waitlist" && <WaitlistTab ctx={ctx} />}
211
+ {tab === "bookings" && <BookingsTab ctx={ctx} />}
178
212
  {tab === "saved" && <SavedTab />}
179
213
  {tab === "account" && <AccountTab ctx={ctx} />}
180
214
  {tab === "notifications" && <NotificationsTab />}
@@ -192,7 +226,14 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
192
226
  const [isCancelling, setIsCancelling] = useState(false);
193
227
 
194
228
  const membership = user?.membership;
195
- const isActive = membership?.status === "active";
229
+ // Two separate questions, deliberately answered separately:
230
+ // `access.hasAccess` → are the benefits live (true through past_due/grace)
231
+ // `message` → what the member is told, including a failed payment
232
+ // The old `status === "active"` answered both at once and got both wrong for
233
+ // anyone whose card bounced.
234
+ const { access, message } = useMembershipAccess();
235
+ const openBillingPortal = useOpenBillingPortal();
236
+ const [billingError, setBillingError] = useState<string | null>(null);
196
237
 
197
238
  const onCancel = async () => {
198
239
  if (!membership?.id) return;
@@ -205,6 +246,17 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
205
246
  }
206
247
  };
207
248
 
249
+ const onUpdatePayment = async () => {
250
+ setBillingError(null);
251
+ try {
252
+ const { url } = await openBillingPortal.mutateAsync({});
253
+ window.location.href = url;
254
+ } catch (error) {
255
+ const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
256
+ setBillingError(detail || "We couldn't open the billing page. Please contact us.");
257
+ }
258
+ };
259
+
208
260
  return (
209
261
  <div style={card}>
210
262
  <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>Current Membership</h2>
@@ -212,7 +264,19 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
212
264
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
213
265
  <div>
214
266
  <h3 style={{ fontWeight: 700 }}>{membership.membershipTier.name}</h3>
215
- <p style={{ fontSize: 13, opacity: 0.7 }}>{isActive ? "Active" : "Cancelled"}</p>
267
+ <p
268
+ style={{
269
+ fontSize: 13,
270
+ opacity: 0.85,
271
+ fontWeight: message.tone === "warning" ? 700 : 400,
272
+ color: message.tone === "warning" ? t.primary : undefined,
273
+ }}
274
+ >
275
+ {message.label}
276
+ </p>
277
+ {message.detail && (
278
+ <p style={{ fontSize: 13, opacity: 0.8, marginTop: 4, maxWidth: 480 }}>{message.detail}</p>
279
+ )}
216
280
  {!!membership.subscriptionAmount && (
217
281
  <p style={{ fontSize: 14, marginTop: 4 }}>
218
282
  {formatAmount(membership.subscriptionAmount, membership.subscriptionCurrency || currency)} / {membership.billingCycle}
@@ -231,16 +295,25 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
231
295
  )}
232
296
  {membership.endDate && (
233
297
  <p style={{ fontSize: 14, opacity: 0.8 }}>
234
- {isActive ? "Renews" : "Ends"} on {formatDate(membership.endDate)}
298
+ {access.billingState === "active" ? "Renews" : "Ends"} on {formatDate(membership.endDate)}
235
299
  </p>
236
300
  )}
301
+ {billingError && <p style={{ fontSize: 13, opacity: 0.9 }}>{billingError}</p>}
237
302
  </div>
238
303
  ) : (
239
304
  <p style={{ opacity: 0.8 }}>You don&apos;t have an active membership.</p>
240
305
  )}
241
306
 
242
307
  <div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 24 }}>
243
- {isActive && (
308
+ {message.showUpdatePayment && (
309
+ <button onClick={onUpdatePayment} disabled={openBillingPortal.isPending} style={button}>
310
+ {openBillingPortal.isPending ? "Opening…" : "Update payment method"}
311
+ </button>
312
+ )}
313
+ {/* Cancelling stays available for anyone who still HAS access — a
314
+ past_due member must not be trapped in a subscription they can see
315
+ but not leave. */}
316
+ {access.hasAccess && (
244
317
  <button onClick={onCancel} disabled={isCancelling} style={ghostButton}>
245
318
  {isCancelling ? "Cancelling…" : "Cancel"}
246
319
  </button>
@@ -253,6 +326,571 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
253
326
  );
254
327
  }
255
328
 
329
+ // ---- Tickets tab ------------------------------------------------------------
330
+
331
+ /**
332
+ * The buyer's event tickets, and cancelling one inside the policy they were
333
+ * sold under (S.6).
334
+ *
335
+ * Before this, a ticket buyer had no purchase surface at all — the confirmation
336
+ * email and a one-shot finalise page were the only artefacts of the sale.
337
+ *
338
+ * Eligibility is NOT recomputed here. `cancellation.canCancel` and its reason
339
+ * come from the server, which reads the snapshot on the order; mirroring the
340
+ * date arithmetic in the UI would give two answers to the same question and the
341
+ * wrong one would be the one the buyer sees.
342
+ */
343
+ function TicketsTab({ ctx }: { ctx: TabContext }) {
344
+ const { user } = usePublicAuth();
345
+ const { t, card } = useCardStyles();
346
+ const { formatAmount, formatDate, currency } = ctx;
347
+ const { data, isLoading } = useMyTickets(user?.id);
348
+ const cancelTicket = useCancelMyTicket();
349
+ const [pendingId, setPendingId] = useState<string | null>(null);
350
+ const [error, setError] = useState<string | null>(null);
351
+
352
+ const tickets = data?.data ?? [];
353
+
354
+ const onCancel = async (ticket: MyTicket) => {
355
+ setError(null);
356
+ setPendingId(ticket.id);
357
+ try {
358
+ await cancelTicket.mutateAsync({ orderId: ticket.id });
359
+ } catch (err) {
360
+ const message =
361
+ (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
362
+ "We could not cancel this ticket. Please try again.";
363
+ setError(message);
364
+ } finally {
365
+ setPendingId(null);
366
+ }
367
+ };
368
+
369
+ return (
370
+ <div style={card}>
371
+ <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>My Tickets</h2>
372
+ {error && (
373
+ <div style={{ marginBottom: 16, padding: 12, borderRadius: t.cornerRadius, border: `1px solid ${t.primary}40`, fontSize: 14 }}>
374
+ {error}
375
+ </div>
376
+ )}
377
+ {isLoading ? (
378
+ <Loading />
379
+ ) : tickets.length > 0 ? (
380
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
381
+ {tickets.map((ticket) => {
382
+ const cancelled = !!ticket.selfCancelledAt;
383
+ return (
384
+ <div
385
+ key={ticket.id}
386
+ style={{ border: `1px solid ${t.primary}20`, borderRadius: t.cornerRadius, padding: 16, opacity: cancelled ? 0.6 : 1 }}
387
+ >
388
+ <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12, gap: 12 }}>
389
+ <div>
390
+ <div style={{ fontWeight: 600 }}>{ticket.eventTitle}</div>
391
+ <div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(ticket.eventDateTime)}</div>
392
+ </div>
393
+ {cancelled && <span style={{ fontSize: 13, color: t.primary }}>Cancelled</span>}
394
+ </div>
395
+
396
+ {ticket.items.map((item) => (
397
+ <div key={item.id} style={{ display: "flex", justifyContent: "space-between", fontSize: 14, padding: "4px 0" }}>
398
+ <span>
399
+ {item.ticketTitle ?? "Ticket"} × {item.quantity}
400
+ </span>
401
+ <span>{formatAmount(Number(item.price) * item.quantity, ticket.currency || currency)}</span>
402
+ </div>
403
+ ))}
404
+
405
+ <div style={{ display: "flex", justifyContent: "space-between", fontWeight: 700, marginTop: 12, paddingTop: 12, borderTop: `1px solid ${t.primary}20` }}>
406
+ <span>Total</span>
407
+ <span style={{ color: t.primary }}>{formatAmount(Number(ticket.totalAmount), ticket.currency || currency)}</span>
408
+ </div>
409
+
410
+ {/*
411
+ Wallet passes (2.3). Renders NOTHING unless the API offers a
412
+ real pass for a real pass id — which, with no signing
413
+ credentials configured, is every ticket today. Deliberately
414
+ not wrapped in a condition of its own: the component owns that
415
+ decision, and a second copy of the rule here is how one of the
416
+ two ends up out of date and leaks the feature's existence.
417
+
418
+ Eligibility (cancelled order, rotating QR) is the server's
419
+ call, exactly as it is for cancellation above.
420
+ */}
421
+ <WalletPassButtons passIds={myTicketPassIds(ticket)} variant="compact" style={{ marginTop: 12 }} />
422
+
423
+ {/*
424
+ Transfer (2.2). The email a transfer sends points at
425
+ `/i/tickets/claim`, so this affordance and that route ship
426
+ together — a send with nowhere to land is undeliverable in
427
+ practice.
428
+
429
+ Renders nothing when the payload carries no `TN-…` pass ids
430
+ (an older API build), because every pass-scoped endpoint would
431
+ 404. Whether THIS pass may be handed on is the server's call,
432
+ answered on submit in its own words — not hidden behind a
433
+ guess here.
434
+ */}
435
+ <TicketTransferPanel passIds={myTicketPassIds(ticket)} style={{ marginTop: 12 }} />
436
+
437
+ {/* The terms they agreed to, in the artist's own words where given. */}
438
+ {ticket.cancellation.description && (
439
+ <div style={{ fontSize: 13, opacity: 0.75, marginTop: 12 }}>{ticket.cancellation.description}</div>
440
+ )}
441
+ {ticket.cancellation.terms && (
442
+ <div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>{ticket.cancellation.terms}</div>
443
+ )}
444
+
445
+ {!cancelled && ticket.cancellation.canCancel && (
446
+ <button
447
+ onClick={() => void onCancel(ticket)}
448
+ disabled={pendingId === ticket.id}
449
+ style={{
450
+ marginTop: 12,
451
+ padding: "8px 14px",
452
+ borderRadius: t.cornerRadius,
453
+ border: `1px solid ${t.primary}40`,
454
+ background: "transparent",
455
+ color: t.text,
456
+ cursor: pendingId === ticket.id ? "wait" : "pointer",
457
+ fontWeight: 600,
458
+ }}
459
+ >
460
+ {pendingId === ticket.id ? "Cancelling…" : "Cancel ticket"}
461
+ </button>
462
+ )}
463
+ </div>
464
+ );
465
+ })}
466
+ </div>
467
+ ) : (
468
+ <p style={{ fontSize: 14, opacity: 0.7 }}>You have no tickets yet.</p>
469
+ )}
470
+ </div>
471
+ );
472
+ }
473
+
474
+ // ---- Waitlist tab -----------------------------------------------------------
475
+
476
+ /**
477
+ * Every live place the buyer holds on a sold-out tier (2.6).
478
+ *
479
+ * Two states, and they are genuinely different things:
480
+ *
481
+ * WAITING — a 1-based `position` in the queue.
482
+ * NOTIFIED — an OFFER with a deadline, and `position: null`. An offer is not a
483
+ * queue place, so it is never rendered as one; the deadline is the
484
+ * whole point and gets the emphasis.
485
+ *
486
+ * Nothing is recomputed here: position, offer state and the claim deadline all
487
+ * arrive from the server on each read, the same rule the tickets tab follows for
488
+ * cancellation eligibility.
489
+ */
490
+ function WaitlistTab({ ctx }: { ctx: TabContext }) {
491
+ const { user } = usePublicAuth();
492
+ const { t, card } = useCardStyles();
493
+ const { formatDate } = ctx;
494
+ const { places, isLoading } = useMyWaitlistPlaces(user?.id);
495
+
496
+ return (
497
+ <div style={card}>
498
+ <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>My Waitlists</h2>
499
+ {isLoading ? (
500
+ <Loading />
501
+ ) : places.length > 0 ? (
502
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
503
+ {places.map((place) => (
504
+ <WaitlistPlaceRow key={place.id} place={place} formatDate={formatDate} />
505
+ ))}
506
+ </div>
507
+ ) : (
508
+ <p style={{ fontSize: 14, opacity: 0.7 }}>You&apos;re not on any waitlists.</p>
509
+ )}
510
+ </div>
511
+ );
512
+ }
513
+
514
+ function WaitlistPlaceRow({
515
+ place,
516
+ formatDate,
517
+ }: {
518
+ place: WaitlistPlace;
519
+ formatDate: (value: string | number | Date) => string;
520
+ }) {
521
+ const { t } = useCardStyles();
522
+ const leave = useLeaveEventWaitlist(place.eventId);
523
+ const [error, setError] = useState<string | null>(null);
524
+
525
+ const onLeave = async () => {
526
+ setError(null);
527
+ try {
528
+ await leave.mutateAsync({ entryId: place.id });
529
+ } catch (err) {
530
+ const message =
531
+ (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
532
+ "We could not remove you from this waitlist. Please try again.";
533
+ setError(message);
534
+ }
535
+ };
536
+
537
+ const deadline = place.offer.claimExpiresAt ? new Date(place.offer.claimExpiresAt) : null;
538
+
539
+ return (
540
+ <div
541
+ style={{
542
+ border: `1px solid ${t.primary}${place.offer.offerOpen ? "80" : "20"}`,
543
+ borderRadius: t.cornerRadius,
544
+ padding: 16,
545
+ }}
546
+ >
547
+ <div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
548
+ <div>
549
+ <div style={{ fontWeight: 600 }}>{place.event?.title ?? "Event"}</div>
550
+ <div style={{ fontSize: 13, opacity: 0.7 }}>
551
+ {place.ticket?.title ?? "Ticket"} · {place.quantity} {place.quantity === 1 ? "ticket" : "tickets"}
552
+ </div>
553
+ {place.event?.dateTime && (
554
+ <div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(place.event.dateTime)}</div>
555
+ )}
556
+ </div>
557
+ {place.offer.offerOpen ? (
558
+ <span style={{ fontWeight: 700, color: t.primary }}>Tickets available</span>
559
+ ) : place.position != null ? (
560
+ <span style={{ fontSize: 14, opacity: 0.85 }}>
561
+ Position <strong style={{ color: t.primary }}>#{place.position}</strong>
562
+ </span>
563
+ ) : (
564
+ <span style={{ fontSize: 14, opacity: 0.85 }}>You&apos;re on the list</span>
565
+ )}
566
+ </div>
567
+
568
+ {place.offer.offerOpen && deadline && (
569
+ <div
570
+ style={{
571
+ marginTop: 12,
572
+ padding: "10px 12px",
573
+ borderRadius: t.cornerRadius,
574
+ border: `1px solid ${t.primary}4d`,
575
+ fontSize: 14,
576
+ }}
577
+ >
578
+ <strong>Buy within {formatCountdown(place.offer.secondsRemaining ?? 0)}</strong> — your offer closes{" "}
579
+ {deadline.toLocaleString(undefined, {
580
+ weekday: "short",
581
+ day: "numeric",
582
+ month: "short",
583
+ hour: "numeric",
584
+ minute: "2-digit",
585
+ })}
586
+ . Tickets are not reserved until you buy them.
587
+ </div>
588
+ )}
589
+
590
+ {error && <p style={{ color: "#ef4444", fontSize: 13, marginTop: 12 }}>{error}</p>}
591
+
592
+ <button
593
+ onClick={() => void onLeave()}
594
+ disabled={leave.isPending}
595
+ style={{
596
+ marginTop: 12,
597
+ padding: "8px 14px",
598
+ borderRadius: t.cornerRadius,
599
+ border: `1px solid ${t.primary}40`,
600
+ background: "transparent",
601
+ color: t.text,
602
+ cursor: leave.isPending ? "wait" : "pointer",
603
+ fontWeight: 600,
604
+ }}
605
+ >
606
+ {leave.isPending ? "Leaving…" : "Leave waitlist"}
607
+ </button>
608
+ </div>
609
+ );
610
+ }
611
+
612
+ // ---- Bookings tab -----------------------------------------------------------
613
+
614
+ /**
615
+ * The buyer's coaching sessions, and moving one (S.6 / bookings § 1.4).
616
+ *
617
+ * Session buyers had no purchase surface at all: `GET /public/coaching/bookings/mine`
618
+ * and the reschedule action shipped with nothing that could reach them.
619
+ *
620
+ * A booking is deliberately NOT folded into the Orders tab — that tab reads
621
+ * `/public/orders` and covers product orders only, so a session would never
622
+ * appear there however hard one looked.
623
+ *
624
+ * Nothing is recomputed here. `canReschedule` and `rescheduleReason` arrive on
625
+ * every row, and the slot grid the picker offers is the server's own
626
+ * availability read — which already excludes taken slots and hours the coach is
627
+ * busy on any of their services.
628
+ *
629
+ * `cancellation` comes from the POLICY SNAPSHOT taken on the booking at
630
+ * purchase, never the coach's current setting, and `canCancel` is the only
631
+ * thing that decides whether a cancel control is drawn. The cancel is
632
+ * idempotent server-side (one conditional UPDATE claims it before any provider
633
+ * call), so a double-click refunds once.
634
+ */
635
+ function BookingsTab({ ctx }: { ctx: TabContext }) {
636
+ const { user } = usePublicAuth();
637
+ const { t, card } = useCardStyles();
638
+ const { data, isLoading } = useMyBookings(user?.id);
639
+
640
+ const bookings = data?.data ?? [];
641
+
642
+ return (
643
+ <div style={card}>
644
+ <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>My Sessions</h2>
645
+ {isLoading ? (
646
+ <Loading />
647
+ ) : bookings.length > 0 ? (
648
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
649
+ {bookings.map((booking) => (
650
+ <BookingRow key={booking.id} booking={booking} ctx={ctx} />
651
+ ))}
652
+ </div>
653
+ ) : (
654
+ <p style={{ fontSize: 14, opacity: 0.7 }}>You have no sessions booked.</p>
655
+ )}
656
+ </div>
657
+ );
658
+ }
659
+
660
+ /** The session's window, in the coach's timezone where one is set. */
661
+ function formatSessionWindow(booking: MyBooking): string {
662
+ const zone = booking.coachingProductTimezone ?? undefined;
663
+ const start = new Date(booking.sessionStartTime);
664
+ const end = new Date(booking.sessionEndTime);
665
+ const day = start.toLocaleString(undefined, {
666
+ weekday: "short",
667
+ day: "numeric",
668
+ month: "short",
669
+ year: "numeric",
670
+ timeZone: zone,
671
+ });
672
+ const from = start.toLocaleString(undefined, { hour: "numeric", minute: "2-digit", timeZone: zone });
673
+ const to = end.toLocaleString(undefined, { hour: "numeric", minute: "2-digit", timeZone: zone });
674
+ return `${day}, ${from} – ${to}${zone ? ` (${zone})` : ""}`;
675
+ }
676
+
677
+ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
678
+ const { t } = useCardStyles();
679
+ const { formatAmount, currency } = ctx;
680
+ const cancelBooking = useCancelMyBooking();
681
+ const [picking, setPicking] = useState(false);
682
+ const [error, setError] = useState<string | null>(null);
683
+ const cancelled = booking.status === "canceled" || !!booking.selfCancelledAt;
684
+
685
+ const actionButton: React.CSSProperties = {
686
+ padding: "8px 14px",
687
+ borderRadius: t.cornerRadius,
688
+ border: `1px solid ${t.primary}40`,
689
+ background: "transparent",
690
+ color: t.text,
691
+ cursor: "pointer",
692
+ fontWeight: 600,
693
+ };
694
+
695
+ const onCancel = async () => {
696
+ setError(null);
697
+ try {
698
+ await cancelBooking.mutateAsync({ bookingId: booking.id });
699
+ } catch (err) {
700
+ // The server names the rule that stopped it (outside the notice window,
701
+ // already cancelled, a refund that cannot be made automatically). Its
702
+ // wording, not ours.
703
+ const message =
704
+ (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
705
+ "We could not cancel this session. Please try again.";
706
+ setError(message);
707
+ }
708
+ };
709
+
710
+ return (
711
+ <div
712
+ style={{
713
+ border: `1px solid ${t.primary}20`,
714
+ borderRadius: t.cornerRadius,
715
+ padding: 16,
716
+ opacity: cancelled ? 0.6 : 1,
717
+ }}
718
+ >
719
+ <div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
720
+ <div>
721
+ <div style={{ fontWeight: 600 }}>{booking.coachingProductTitle}</div>
722
+ <div style={{ fontSize: 13, opacity: 0.7 }}>{formatSessionWindow(booking)}</div>
723
+ {booking.coachingProductDurationMinutes != null && (
724
+ <div style={{ fontSize: 13, opacity: 0.7 }}>{booking.coachingProductDurationMinutes} minutes</div>
725
+ )}
726
+ </div>
727
+ <div style={{ textAlign: "right" }}>
728
+ {cancelled && <div style={{ fontSize: 13, color: t.primary }}>Cancelled</div>}
729
+ {booking.totalAmount != null && (
730
+ <div style={{ fontWeight: 700, color: t.primary }}>
731
+ {formatAmount(booking.totalAmount, booking.currency || currency)}
732
+ </div>
733
+ )}
734
+ </div>
735
+ </div>
736
+
737
+ {/* The terms they booked under, in the artist's own words where given. */}
738
+ {booking.cancellation.description && (
739
+ <div style={{ fontSize: 13, opacity: 0.75, marginTop: 12 }}>{booking.cancellation.description}</div>
740
+ )}
741
+ {booking.cancellation.terms && (
742
+ <div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>{booking.cancellation.terms}</div>
743
+ )}
744
+
745
+ {/*
746
+ The action row. Both controls are drawn from the SERVER's flags —
747
+ `canReschedule` and `cancellation.canCancel` — never from eligibility
748
+ re-derived in the browser, which would give two answers to one question.
749
+ */}
750
+ {error && (
751
+ <p style={{ color: "#ef4444", fontSize: 13, marginTop: 12 }} role="alert">
752
+ {error}
753
+ </p>
754
+ )}
755
+
756
+ <div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
757
+ {booking.canReschedule && !picking && (
758
+ <button onClick={() => setPicking(true)} style={actionButton}>
759
+ Reschedule
760
+ </button>
761
+ )}
762
+ {booking.cancellation.canCancel && (
763
+ <button
764
+ onClick={() => void onCancel()}
765
+ disabled={cancelBooking.isPending}
766
+ style={{ ...actionButton, cursor: cancelBooking.isPending ? "wait" : "pointer" }}
767
+ >
768
+ {cancelBooking.isPending ? "Cancelling…" : "Cancel session"}
769
+ </button>
770
+ )}
771
+ </div>
772
+
773
+ {picking && <RescheduleSlotPicker booking={booking} onDone={() => setPicking(false)} />}
774
+ </div>
775
+ );
776
+ }
777
+
778
+ /**
779
+ * Pick another time for a session already bought.
780
+ *
781
+ * The grid is `GET /public/coaching/products/:id/availability` over the next
782
+ * fortnight — the same read the original purchase used, which already drops
783
+ * taken slots and hours the coach is busy on any of their services. It is not
784
+ * filtered further here: a slot this list offers and the server then refuses
785
+ * (two buyers racing for one hour, decided by a database EXCLUDE constraint)
786
+ * comes back as the server's own 409 message, which is the honest answer.
787
+ */
788
+ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone: () => void }) {
789
+ const { t } = useCardStyles();
790
+ const reschedule = useRescheduleBooking();
791
+ const [error, setError] = useState<string | null>(null);
792
+ const [pendingSlotId, setPendingSlotId] = useState<string | null>(null);
793
+
794
+ // A fortnight from now. Computed once per mount rather than per render, so the
795
+ // query key does not change on every tick and refetch forever.
796
+ const [window] = useState(() => {
797
+ const from = new Date();
798
+ const to = new Date(from.getTime() + 14 * 24 * 3600_000);
799
+ return { from: from.toISOString(), to: to.toISOString() };
800
+ });
801
+
802
+ const { data: slots, isLoading } = useCoachingAvailability(booking.coachingProductId, window.from, window.to);
803
+ const zone = booking.coachingProductTimezone ?? undefined;
804
+
805
+ const onPick = async (slotId: string) => {
806
+ setError(null);
807
+ setPendingSlotId(slotId);
808
+ try {
809
+ await reschedule.mutateAsync({ bookingId: booking.id, slotId });
810
+ onDone();
811
+ } catch (err) {
812
+ const message =
813
+ (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
814
+ "We could not move this session. Please try another time.";
815
+ setError(message);
816
+ } finally {
817
+ setPendingSlotId(null);
818
+ }
819
+ };
820
+
821
+ const open = (slots ?? []).filter((slot) => slot.id !== booking.coachingBookingSlotId);
822
+
823
+ return (
824
+ <div style={{ marginTop: 12, padding: 12, borderRadius: t.cornerRadius, border: `1px solid ${t.primary}4d` }}>
825
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
826
+ <strong style={{ fontSize: 14 }}>Choose a new time</strong>
827
+ <button
828
+ onClick={onDone}
829
+ style={{
830
+ padding: "4px 10px",
831
+ borderRadius: t.cornerRadius,
832
+ border: `1px solid ${t.primary}40`,
833
+ background: "transparent",
834
+ color: t.text,
835
+ cursor: "pointer",
836
+ fontSize: 13,
837
+ }}
838
+ >
839
+ Close
840
+ </button>
841
+ </div>
842
+
843
+ {error && (
844
+ <p style={{ color: "#ef4444", fontSize: 13, marginTop: 10 }} role="alert">
845
+ {error}
846
+ </p>
847
+ )}
848
+
849
+ {isLoading ? (
850
+ <Loading />
851
+ ) : open.length > 0 ? (
852
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 12 }}>
853
+ {open.map((slot) => {
854
+ const start = new Date(slot.startTime);
855
+ const busy = pendingSlotId === slot.id;
856
+ return (
857
+ <button
858
+ key={slot.id}
859
+ onClick={() => void onPick(slot.id)}
860
+ disabled={reschedule.isPending}
861
+ style={{
862
+ padding: "8px 12px",
863
+ borderRadius: t.cornerRadius,
864
+ border: `1px solid ${t.primary}40`,
865
+ background: "transparent",
866
+ color: t.text,
867
+ cursor: reschedule.isPending ? "wait" : "pointer",
868
+ fontSize: 13,
869
+ }}
870
+ >
871
+ {busy
872
+ ? "Moving…"
873
+ : start.toLocaleString(undefined, {
874
+ weekday: "short",
875
+ day: "numeric",
876
+ month: "short",
877
+ hour: "numeric",
878
+ minute: "2-digit",
879
+ timeZone: zone,
880
+ })}
881
+ </button>
882
+ );
883
+ })}
884
+ </div>
885
+ ) : (
886
+ <p style={{ fontSize: 13, opacity: 0.7, marginTop: 12 }}>
887
+ No other times are open in the next two weeks.
888
+ </p>
889
+ )}
890
+ </div>
891
+ );
892
+ }
893
+
256
894
  // ---- Orders tab -------------------------------------------------------------
257
895
 
258
896
  function OrdersTab({ ctx }: { ctx: TabContext }) {