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