@tribe-nest/forge 3.52.0 → 3.54.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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/contexts/AppAuthContext.tsx +3 -2
  3. package/src/contexts/AudioPlayerContext.tsx +3 -2
  4. package/src/contexts/CartContext.tsx +3 -2
  5. package/src/contexts/PublicAuthContext.tsx +3 -2
  6. package/src/data/queries/useEvents.ts +14 -0
  7. package/src/data/queries/useMyCourses.ts +42 -0
  8. package/src/data/queries/useMyTickets.ts +49 -0
  9. package/src/data/queries/usePaymentFlow.ts +8 -0
  10. package/src/data/queries/useWebsiteAgent.ts +2 -0
  11. package/src/i18n/de.json +47 -0
  12. package/src/i18n/en.json +47 -0
  13. package/src/i18n/index.ts +3 -2
  14. package/src/index.ts +23 -0
  15. package/src/provider/ForgeAppProvider.tsx +8 -1
  16. package/src/provider/ForgeProvider.tsx +3 -2
  17. package/src/provider/SiteConfigProvider.tsx +3 -2
  18. package/src/runtime/RemotePage.tsx +110 -0
  19. package/src/runtime/hostRuntime.ts +84 -0
  20. package/src/runtime/pages.spec.ts +41 -0
  21. package/src/runtime/pages.ts +183 -0
  22. package/src/runtime/pagesClient.ts +127 -0
  23. package/src/runtime/registry.spec.ts +45 -0
  24. package/src/runtime/registry.ts +102 -0
  25. package/src/server/jobs.ts +4 -1
  26. package/src/server/platformEvents.generated.ts +92 -0
  27. package/src/types/models.ts +58 -0
  28. package/src/ui/headless/agent/_tests/pathExclusion.spec.ts +52 -0
  29. package/src/ui/headless/agent/_tests/useCurrentPath.spec.tsx +54 -0
  30. package/src/ui/headless/agent/pathExclusion.ts +53 -0
  31. package/src/ui/headless/agent/useAiAgent.ts +10 -1
  32. package/src/ui/headless/agent/useCurrentPath.ts +49 -0
  33. package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
  34. package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
  35. package/src/ui/headless/event/ticketApproval.ts +109 -0
  36. package/src/ui/headless/event/useEventCheckout.ts +74 -4
  37. package/src/ui/headless/index.ts +11 -0
  38. package/src/ui/index.ts +18 -0
  39. package/src/ui/payment/ForgePaymentProvider.tsx +15 -0
  40. package/src/ui/payment/ForgeStripePayment.tsx +37 -3
  41. package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
  42. package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
  43. package/src/ui/shell/TribeNestApp.tsx +6 -0
  44. package/src/ui/styled/AccountDashboard.tsx +181 -14
  45. package/src/ui/styled/AiAgentWidget.tsx +3 -2
  46. package/src/ui/styled/DocumentSigningPage.tsx +516 -0
  47. package/src/ui/styled/EventConfirmation.tsx +106 -9
  48. package/src/ui/styled/EventTickets.tsx +107 -13
  49. package/src/ui/styled/ReviewRequestPage.tsx +214 -0
  50. package/src/ui/styled/forge-utilities.css +310 -0
  51. package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
  52. package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
  53. package/src/utils/ticketOrderOutcome.ts +57 -1
@@ -1,3 +1,4 @@
1
+ import type { DeferredChargeStrategy, PaymentSettlement } from "../../types/models";
1
2
  import { createContext, useContext, type ReactNode } from "react";
2
3
  import { ForgeStripePayment } from "./ForgeStripePayment";
3
4
 
@@ -16,6 +17,20 @@ export interface PaymentRenderProps {
16
17
  mode?: "redirect" | "manual";
17
18
  onSucceeded?: () => void;
18
19
  onFailed?: (message?: string) => void;
20
+ /**
21
+ * `"deferred"` when the charge is committed now and taken only if the
22
+ * organiser approves (a ticket request). The built-in renderer then labels
23
+ * the button "Authorize {amount}" and says under it that the card is charged
24
+ * only on approval. Absent on every ordinary sale, so a custom renderer that
25
+ * ignores it keeps working exactly as before.
26
+ */
27
+ settlement?: PaymentSettlement;
28
+ /**
29
+ * How the provider defers: `"authorization"` shows a temporary hold on the
30
+ * fan's statement and the renderer says so; `"saved_instrument"` takes
31
+ * nothing until approval. Only sent with `settlement: "deferred"`.
32
+ */
33
+ deferredStrategy?: DeferredChargeStrategy | null;
19
34
  }
20
35
 
21
36
  export type PaymentRenderer = (props: PaymentRenderProps) => ReactNode;
@@ -5,7 +5,9 @@ import { useForgeTheme } from "../theme/ForgeThemeProvider";
5
5
  import { readableTextOn } from "../theme/contrast";
6
6
  import { useFormatCurrency } from "../format/useFormatCurrency";
7
7
  import { useSiteConfig } from "../../data/queries/useWebsite";
8
+ import { useForgeT } from "../../i18n";
8
9
  import type { PaymentRenderProps } from "./ForgePaymentProvider";
10
+ import { deferredChargeNotice, isDeferredSettlement, isManualConfirmSuccess } from "./stripeConfirmOutcome";
9
11
 
10
12
  function alpha(hex: number) {
11
13
  return Math.round(hex * 255)
@@ -30,6 +32,8 @@ export function ForgeStripePayment({
30
32
  mode = "redirect",
31
33
  onSucceeded,
32
34
  onFailed,
35
+ settlement,
36
+ deferredStrategy,
33
37
  }: PaymentRenderProps) {
34
38
  const theme = useForgeTheme();
35
39
  const { data: siteConfig } = useSiteConfig();
@@ -91,7 +95,16 @@ export function ForgeStripePayment({
91
95
 
92
96
  return (
93
97
  <Elements options={{ clientSecret, appearance, loader: "auto" }} stripe={stripePromise}>
94
- <Inner returnUrl={returnUrl} amount={amount} currency={currency} mode={mode} onSucceeded={onSucceeded} onFailed={onFailed} />
98
+ <Inner
99
+ returnUrl={returnUrl}
100
+ amount={amount}
101
+ currency={currency}
102
+ mode={mode}
103
+ onSucceeded={onSucceeded}
104
+ onFailed={onFailed}
105
+ settlement={settlement}
106
+ deferredStrategy={deferredStrategy}
107
+ />
95
108
  </Elements>
96
109
  );
97
110
  }
@@ -103,15 +116,26 @@ function Inner({
103
116
  mode = "redirect",
104
117
  onSucceeded,
105
118
  onFailed,
119
+ settlement,
120
+ deferredStrategy,
106
121
  }: Omit<PaymentRenderProps, "clientSecret">) {
107
122
  const theme = useForgeTheme();
123
+ const t = useForgeT();
108
124
  const { formatCurrency } = useFormatCurrency();
109
125
  const stripe = useStripe();
110
126
  const elements = useElements();
111
127
  const [message, setMessage] = useState("");
112
128
  const [isLoading, setIsLoading] = useState(false);
113
129
 
114
- const buttonText = `Pay ${formatCurrency(amount, currency || undefined)} Now`;
130
+ // A deferred charge is not a payment yet: the button says what actually
131
+ // happens when it is pressed, and the line under it says when the money
132
+ // moves. That line is content, not help: a fan reading "Authorize" without
133
+ // it has been told their card was committed and nothing about why.
134
+ const deferred = isDeferredSettlement(settlement);
135
+ const formattedAmount = formatCurrency(amount, currency || undefined);
136
+ const buttonText = deferred
137
+ ? t("forge.forge_stripe_payment.authorize", { amount: formattedAmount })
138
+ : `Pay ${formattedAmount} Now`;
115
139
 
116
140
  const handleSubmit = useCallback(async () => {
117
141
  if (!stripe || !elements) return;
@@ -131,7 +155,9 @@ function Inner({
131
155
  onFailed?.(msg);
132
156
  return;
133
157
  }
134
- if (paymentIntent?.status === "succeeded" || paymentIntent?.status === "processing") {
158
+ // `requires_capture` is a deferred charge that went through: the card is
159
+ // authorised and the platform captures it on approval. See the helper.
160
+ if (isManualConfirmSuccess(paymentIntent?.status)) {
135
161
  setIsLoading(false);
136
162
  onSucceeded?.();
137
163
  return;
@@ -166,6 +192,14 @@ function Inner({
166
192
  >
167
193
  {isLoading ? "Processing…" : buttonText}
168
194
  </button>
195
+ {deferred && (
196
+ <p data-testid="deferred-charge-notice" style={{ margin: 0, fontSize: 13, opacity: 0.8, lineHeight: 1.5 }}>
197
+ {t("forge.forge_stripe_payment.deferred_charged_on_approval")}
198
+ {deferredChargeNotice(deferredStrategy) === "authorization" && (
199
+ <> {t("forge.forge_stripe_payment.deferred_hold_on_statement")}</>
200
+ )}
201
+ </p>
202
+ )}
169
203
  </form>
170
204
  );
171
205
  }
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ deferredChargeNotice,
4
+ isDeferredSettlement,
5
+ isManualConfirmSuccess,
6
+ MANUAL_CONFIRM_SUCCESS_STATUSES,
7
+ } from "../stripeConfirmOutcome";
8
+
9
+ /**
10
+ * The one-line fix from the curated-access design (section 6.2, step 2): a
11
+ * deferred charge leaves the PaymentIntent at `requires_capture`, and the
12
+ * manual-mode branch of `ForgeStripePayment` used to read anything that was
13
+ * not `succeeded`/`processing` as "Payment did not complete". The decision is
14
+ * a pure function now, so the renderer cannot drift back.
15
+ */
16
+ describe("isManualConfirmSuccess", () => {
17
+ it("REGRESSION: counts an authorised, uncaptured intent as the fan being done", () => {
18
+ expect(isManualConfirmSuccess("requires_capture")).toBe(true);
19
+ });
20
+
21
+ it("keeps the two statuses it always accepted", () => {
22
+ expect(isManualConfirmSuccess("succeeded")).toBe(true);
23
+ expect(isManualConfirmSuccess("processing")).toBe(true);
24
+ });
25
+
26
+ it("still refuses every status that means the fan is not done", () => {
27
+ for (const status of ["requires_payment_method", "requires_confirmation", "requires_action", "canceled"]) {
28
+ expect(isManualConfirmSuccess(status)).toBe(false);
29
+ }
30
+ expect(isManualConfirmSuccess(undefined)).toBe(false);
31
+ expect(isManualConfirmSuccess(null)).toBe(false);
32
+ expect(isManualConfirmSuccess("")).toBe(false);
33
+ });
34
+
35
+ it("exposes the same set the renderer reads, so a custom renderer can share it", () => {
36
+ expect([...MANUAL_CONFIRM_SUCCESS_STATUSES].sort()).toEqual(["processing", "requires_capture", "succeeded"]);
37
+ });
38
+ });
39
+
40
+ describe("isDeferredSettlement", () => {
41
+ it("is true only for an explicit deferred charge", () => {
42
+ expect(isDeferredSettlement("deferred")).toBe(true);
43
+ expect(isDeferredSettlement("immediate")).toBe(false);
44
+ // An older API build sends nothing, and nothing must render as a sale.
45
+ expect(isDeferredSettlement(undefined)).toBe(false);
46
+ expect(isDeferredSettlement(null)).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe("deferredChargeNotice", () => {
51
+ it("promises a visible hold only when the provider actually places one", () => {
52
+ expect(deferredChargeNotice("authorization")).toBe("authorization");
53
+ expect(deferredChargeNotice("saved_instrument")).toBe("saved_instrument");
54
+ });
55
+
56
+ it("falls back to the quieter line for an unknown strategy", () => {
57
+ // Promising a hold that never appears is the larger lie of the two.
58
+ expect(deferredChargeNotice(undefined)).toBe("saved_instrument");
59
+ expect(deferredChargeNotice(null)).toBe("saved_instrument");
60
+ });
61
+ });
@@ -0,0 +1,50 @@
1
+ import type { DeferredChargeStrategy, PaymentSettlement } from "../../types/models";
2
+
3
+ /**
4
+ * The PaymentIntent statuses `confirmPayment` can leave behind that mean "the
5
+ * fan's part is done", in the manual (confirm-in-place) branch of
6
+ * `ForgeStripePayment`.
7
+ *
8
+ * succeeded charged
9
+ * processing a delayed-notification method (bank debit); charged later
10
+ * requires_capture AUTHORISED, not captured: a deferred charge. The platform
11
+ * captures it when the organiser approves a ticket request
12
+ * (curated access, feature 3) and cancels it otherwise.
13
+ *
14
+ * Before `requires_capture` was here a request checkout confirmed the card,
15
+ * Stripe answered exactly what a manual-capture intent answers, and the
16
+ * renderer told the fan "Payment did not complete" while the order was already
17
+ * on its way to `pending_approval`. Anything else (`requires_payment_method`,
18
+ * `requires_action` that did not resolve, `canceled`) is not done.
19
+ */
20
+ export const MANUAL_CONFIRM_SUCCESS_STATUSES: ReadonlySet<string> = new Set([
21
+ "succeeded",
22
+ "processing",
23
+ "requires_capture",
24
+ ]);
25
+
26
+ /** Does this post-confirm PaymentIntent status count as the fan having finished? */
27
+ export function isManualConfirmSuccess(status: string | null | undefined): boolean {
28
+ return !!status && MANUAL_CONFIRM_SUCCESS_STATUSES.has(status);
29
+ }
30
+
31
+ /** A deferred charge: the card is committed now and taken only on approval. */
32
+ export function isDeferredSettlement(settlement: PaymentSettlement | null | undefined): boolean {
33
+ return settlement === "deferred";
34
+ }
35
+
36
+ /**
37
+ * Which explanation goes under the pay button on a deferred charge.
38
+ *
39
+ * `authorization` places a hold the fan will SEE on their statement, so the
40
+ * line has to say so or the first thing they notice is a pending charge they
41
+ * were promised would not happen. `saved_instrument` (a provider that stores
42
+ * the card and charges it later) takes nothing, and the line says only that.
43
+ * An unknown strategy is treated as the quieter of the two: promising a hold
44
+ * that never appears is the lesser lie.
45
+ */
46
+ export function deferredChargeNotice(
47
+ strategy: DeferredChargeStrategy | null | undefined,
48
+ ): "authorization" | "saved_instrument" {
49
+ return strategy === "authorization" ? "authorization" : "saved_instrument";
50
+ }
@@ -16,6 +16,12 @@ import { previewDiagnosticsEnabled, resolvePreviewDiagnostics } from "./diagnost
16
16
  import type { ForgeSsrDiagnostics } from "../../types/diagnostics";
17
17
  import { captureAttributionRefFromUrl, readAttributionRef } from "../../utils/attribution";
18
18
  import { captureLandingFromUrl, postLandingBeacon } from "../../utils/landing";
19
+ import { registerHostRuntime } from "../../runtime/hostRuntime";
20
+
21
+ // Module scope, not a hook. The hosted `/i/*` bundle can be imported by the
22
+ // very first page a visitor lands on, so the runtime it reads has to be
23
+ // published before any component renders, not during one.
24
+ registerHostRuntime();
19
25
 
20
26
  // Captures both attribution signals on first paint: the tracked-link `?tn_ref`
21
27
  // (stripped afterwards) and the first-touch landing snapshot (all utm/click-id
@@ -4,6 +4,8 @@ import {
4
4
  useUserOrders,
5
5
  useMyTickets,
6
6
  useCancelMyTicket,
7
+ isMyTicketRequestOpenOrEnded,
8
+ myTicketRequestState,
7
9
  myTicketHeldPassIds,
8
10
  myTicketPasses,
9
11
  type MyTicket,
@@ -18,7 +20,9 @@ import {
18
20
  useSavedPosts,
19
21
  useNotificationPreferences,
20
22
  useUpdateNotificationPreference,
23
+ useMyCourses,
21
24
  useMyFilmRentals,
25
+ type MyCourse,
22
26
  useCancelMembership,
23
27
  useMembershipAccess,
24
28
  useOpenBillingPortal,
@@ -62,6 +66,11 @@ export const ACCOUNT_TABS = [
62
66
  // Drawn only when the fan actually holds a rental (see `visibleTabs`), so the
63
67
  // tab does not appear on the account page of every site that never sells one.
64
68
  "rentals",
69
+ // Courses this fan has bought. Beside the other purchase surfaces because it
70
+ // answers the same question they do, and it was the ONE purchase with no
71
+ // answer on this page: a creator's most expensive product, invisible once
72
+ // bought. Drawn only when they hold one (see `visibleTabs`).
73
+ "courses",
65
74
  "saved",
66
75
  "account",
67
76
  "notifications",
@@ -79,6 +88,7 @@ const TAB_LABEL_KEYS: Record<AccountTabKey, string> = {
79
88
  waitlist: "forge.account_dashboard.tab_waitlist",
80
89
  bookings: "forge.account_dashboard.tab_bookings",
81
90
  rentals: "forge.account_dashboard.tab_rentals",
91
+ courses: "forge.account_dashboard.tab_courses",
82
92
  saved: "forge.account_dashboard.tab_saved",
83
93
  account: "forge.account_dashboard.tab_account",
84
94
  notifications: "forge.account_dashboard.tab_notifications",
@@ -116,6 +126,17 @@ export interface AccountDashboardProps {
116
126
  * full-page visit, which works on any site and is merely slower.
117
127
  */
118
128
  onNavigateFilm?: (href: string) => void;
129
+ /**
130
+ * Open a course the fan owns.
131
+ *
132
+ * It must go to the CLASSROOM, keyed by `accessId`, never to the course's
133
+ * sales page. The sales page is where someone decides whether to buy, which
134
+ * is a dead end for a person who already owns it and came here to carry on.
135
+ *
136
+ * Left out, a course row is inert - so a host that draws the tab and omits
137
+ * this gives the fan something that looks clickable and does nothing.
138
+ */
139
+ onNavigateCourse?: (course: MyCourse) => void;
119
140
  /**
120
141
  * Optional currency formatter override. Defaults to Forge's `useFormatCurrency`
121
142
  * (which honors the visitor's selected currency + tenant exchange rates), so the
@@ -183,6 +204,15 @@ interface TabContext {
183
204
  onNavigateMembership?: () => void;
184
205
  onNavigateCommunity?: () => void;
185
206
  onNavigateFilm?: (href: string) => void;
207
+ /**
208
+ * Open a course the fan owns. Host-supplied, like every other navigation
209
+ * target here, because the route shape belongs to the site not to Forge.
210
+ *
211
+ * It must go to the CLASSROOM (`accessId`), never to the course's sales page
212
+ * (`courseId`). The sales page is where someone decides whether to buy, which
213
+ * is a dead end for someone who already owns it and came here to carry on.
214
+ */
215
+ onNavigateCourse?: (course: MyCourse) => void;
186
216
  }
187
217
 
188
218
  // ---- page -------------------------------------------------------------------
@@ -198,6 +228,7 @@ export function AccountDashboard({
198
228
  onNavigateMembership,
199
229
  onNavigateCommunity,
200
230
  onNavigateFilm,
231
+ onNavigateCourse,
201
232
  formatAmount,
202
233
  formatDate,
203
234
  currency,
@@ -219,6 +250,13 @@ export function AccountDashboard({
219
250
  * react-query serves both from one request rather than two.
220
251
  */
221
252
  const { data: rentals } = useMyFilmRentals(user?.id);
253
+ /**
254
+ * Resolved by account OR verified email on the server, so a buyer who never
255
+ * clicked the claim link still sees the course they paid for. Before that,
256
+ * this tab would have been empty for exactly the people who ask where their
257
+ * course went.
258
+ */
259
+ const { data: courses } = useMyCourses({ accountId: user?.id });
222
260
 
223
261
  const fmtAmount = useAmountFormatter(formatAmount);
224
262
  const fmtDate = formatDate ?? defaultFormatDate;
@@ -232,6 +270,7 @@ export function AccountDashboard({
232
270
  onNavigateMembership,
233
271
  onNavigateCommunity,
234
272
  onNavigateFilm,
273
+ onNavigateCourse,
235
274
  };
236
275
 
237
276
  /**
@@ -241,7 +280,12 @@ export function AccountDashboard({
241
280
  * panel underneath it.
242
281
  */
243
282
  const hasRentals = (rentals?.total ?? 0) > 0;
244
- const visibleTabs = ACCOUNT_TABS.filter((key) => key !== "rentals" || hasRentals || tab === "rentals");
283
+ const hasCourses = (courses?.length ?? 0) > 0;
284
+ const visibleTabs = ACCOUNT_TABS.filter(
285
+ (key) =>
286
+ (key !== "rentals" || hasRentals || tab === "rentals") &&
287
+ (key !== "courses" || hasCourses || tab === "courses"),
288
+ );
245
289
 
246
290
  if (!user) return <Loading fullPage />;
247
291
 
@@ -289,6 +333,7 @@ export function AccountDashboard({
289
333
  {tab === "waitlist" && <WaitlistTab ctx={ctx} />}
290
334
  {tab === "bookings" && <BookingsTab ctx={ctx} />}
291
335
  {tab === "rentals" && <RentalsTab ctx={ctx} />}
336
+ {tab === "courses" && <CoursesTab ctx={ctx} courses={courses ?? []} />}
292
337
  {tab === "saved" && <SavedTab />}
293
338
  {tab === "account" && <AccountTab ctx={ctx} />}
294
339
  {tab === "notifications" && <NotificationsTab />}
@@ -556,17 +601,47 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
556
601
  const cancelled = !!ticket.selfCancelledAt;
557
602
  // Absent on an older API build, where every row WAS a purchase.
558
603
  const gifted = ticket.role === "holder";
604
+ /**
605
+ * A ticket REQUEST (curated access, feature 3). While it is pending,
606
+ * or once it ended without a sale, the row is a request and not a
607
+ * purchase: no passes exist, so nothing to cancel, transfer or add
608
+ * to a wallet, and the state line is the whole story. The "Decide
609
+ * by" date is the same one the fan's email carries, so somebody who
610
+ * lost the email still knows where they stand.
611
+ */
612
+ const requestState = myTicketRequestState(ticket);
613
+ const isRequestRow = isMyTicketRequestOpenOrEnded(ticket);
614
+ const requestEnded = isRequestRow && requestState !== "pending";
559
615
  return (
560
616
  <div
561
617
  key={ticket.id}
562
- style={{ border: `1px solid ${t.primary}20`, borderRadius: t.cornerRadius, padding: 16, opacity: cancelled ? 0.6 : 1 }}
618
+ data-testid={requestState ? `my-ticket-request-${requestState}` : undefined}
619
+ style={{
620
+ border: `1px solid ${t.primary}20`,
621
+ borderRadius: t.cornerRadius,
622
+ padding: 16,
623
+ opacity: cancelled || requestEnded ? 0.6 : 1,
624
+ }}
563
625
  >
564
626
  <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12, gap: 12 }}>
565
627
  <div>
566
628
  <div style={{ fontWeight: 600 }}>{ticket.eventTitle}</div>
567
629
  <div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(ticket.eventDateTime)}</div>
568
630
  </div>
569
- {cancelled && (
631
+ {requestState && (
632
+ <span style={{ fontSize: 13, color: t.primary, textAlign: "right" }}>
633
+ {requestState === "pending"
634
+ ? tr("forge.account_dashboard.request_pending")
635
+ : requestState === "approved"
636
+ ? tr("forge.account_dashboard.request_approved")
637
+ : requestState === "declined"
638
+ ? tr("forge.account_dashboard.request_declined")
639
+ : requestState === "expired"
640
+ ? tr("forge.account_dashboard.request_expired")
641
+ : tr("forge.account_dashboard.request_payment_failed")}
642
+ </span>
643
+ )}
644
+ {cancelled && !requestState && (
570
645
  <span style={{ fontSize: 13, color: t.primary }}>{tr("forge.account_dashboard.cancelled")}</span>
571
646
  )}
572
647
  {/* Says WHY this is in the list at all. Without it a
@@ -598,23 +673,57 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
598
673
 
599
674
  {!gifted && (
600
675
  <div style={{ display: "flex", justifyContent: "space-between", fontWeight: 700, marginTop: 12, paddingTop: 12, borderTop: `1px solid ${t.primary}20` }}>
601
- <span>{tr("forge.account_dashboard.total")}</span>
676
+ {/* Nothing has been taken on a pending request, and never
677
+ will be on one that ended: the label says which. */}
678
+ <span>
679
+ {requestState === "pending"
680
+ ? tr("forge.account_dashboard.total_on_approval")
681
+ : tr("forge.account_dashboard.total")}
682
+ </span>
602
683
  <span style={{ color: t.primary }}>{formatAmount(Number(ticket.totalAmount), ticket.currency || currency)}</span>
603
684
  </div>
604
685
  )}
605
686
 
687
+ {/* State the fan must act on (or wait on) stays visible: the
688
+ deadline while pending, the organiser's note when declined,
689
+ and where to go when the seat was released. */}
690
+ {requestState === "pending" && ticket.request?.expiresAt && (
691
+ <div data-testid="my-ticket-decide-by" style={{ fontSize: 13, marginTop: 12, fontWeight: 600 }}>
692
+ {tr("forge.account_dashboard.request_decide_by", { date: formatDate(ticket.request.expiresAt) })}
693
+ </div>
694
+ )}
695
+ {requestState === "pending" && (
696
+ <div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>
697
+ {tr("forge.account_dashboard.request_nothing_charged")}
698
+ </div>
699
+ )}
700
+ {requestState === "declined" && ticket.request?.decisionNote && (
701
+ <div data-testid="my-ticket-decision-note" style={{ fontSize: 13, opacity: 0.75, marginTop: 12 }}>
702
+ {tr("forge.account_dashboard.request_note", { note: ticket.request.decisionNote })}
703
+ </div>
704
+ )}
705
+ {(requestState === "expired" || requestState === "payment_failed") && (
706
+ <div style={{ fontSize: 13, opacity: 0.75, marginTop: 12 }}>
707
+ {tr("forge.account_dashboard.request_seat_released")}
708
+ </div>
709
+ )}
710
+
606
711
  {/* Diarise it — including the Apple option, which needs the
607
712
  platform's own `.ics` and so could not exist before. Null
608
713
  for a called-off show, and the component draws nothing. */}
609
- <AddToCalendar
610
- variant="compact"
611
- title={ticket.eventTitle}
612
- start={ticket.eventDateTime}
613
- end={ticket.eventEndDateTime}
614
- icsUrl={ticket.calendarUrl}
615
- webcalUrl={ticket.calendarWebcalUrl}
616
- style={{ marginTop: 12 }}
617
- />
714
+ {/* Not offered on a request: the fan has not been admitted to
715
+ anything yet, and a calendar entry says they have. */}
716
+ {!isRequestRow && (
717
+ <AddToCalendar
718
+ variant="compact"
719
+ title={ticket.eventTitle}
720
+ start={ticket.eventDateTime}
721
+ end={ticket.eventEndDateTime}
722
+ icsUrl={ticket.calendarUrl}
723
+ webcalUrl={ticket.calendarWebcalUrl}
724
+ style={{ marginTop: 12 }}
725
+ />
726
+ )}
618
727
 
619
728
  {/*
620
729
  Wallet passes (2.3). Renders NOTHING unless the API offers a
@@ -662,7 +771,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
662
771
  <div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>{ticket.cancellation.terms}</div>
663
772
  )}
664
773
 
665
- {!cancelled && ticket.cancellation.canCancel && (
774
+ {!cancelled && !isRequestRow && ticket.cancellation.canCancel && (
666
775
  <button
667
776
  onClick={() => void onCancel(ticket)}
668
777
  disabled={pendingId === ticket.id}
@@ -1357,6 +1466,64 @@ function RentalsTab({ ctx }: { ctx: TabContext }) {
1357
1466
  );
1358
1467
  }
1359
1468
 
1469
+ // ---- Courses tab ------------------------------------------------------------
1470
+
1471
+ /**
1472
+ * What this fan has bought and can open, with a way in.
1473
+ *
1474
+ * `claimed: false` means they never used the claim link. The course is theirs
1475
+ * either way - the server resolves the grant by verified email - so the row says
1476
+ * so plainly rather than staying silent, because "did my purchase register?" is
1477
+ * the question this whole tab exists to stop someone asking.
1478
+ */
1479
+ function CoursesTab({ ctx, courses }: { ctx: TabContext; courses: MyCourse[] }) {
1480
+ const { t, card } = useCardStyles();
1481
+ const tr = useForgeT();
1482
+
1483
+ return (
1484
+ <div style={card}>
1485
+ <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
1486
+ {tr("forge.account_dashboard.courses_title")}
1487
+ </h2>
1488
+
1489
+ {courses.length === 0 ? (
1490
+ <p style={{ color: t.muted }}>{tr("forge.account_dashboard.courses_empty")}</p>
1491
+ ) : (
1492
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
1493
+ {courses.map((course) => (
1494
+ <button
1495
+ key={course.accessId}
1496
+ type="button"
1497
+ onClick={() => ctx.onNavigateCourse?.(course)}
1498
+ style={{
1499
+ textAlign: "left",
1500
+ background: t.surface,
1501
+ border: `1px solid ${t.border}`,
1502
+ borderRadius: t.cornerRadius,
1503
+ padding: 16,
1504
+ cursor: ctx.onNavigateCourse ? "pointer" : "default",
1505
+ color: t.text,
1506
+ }}
1507
+ >
1508
+ <div style={{ fontWeight: 600 }}>{course.title}</div>
1509
+ {/* No description. It is rich text: as a string it shows its own
1510
+ markup, as HTML it drags a sales page's formatting into a list
1511
+ row. The row carries the title and the state instead. */}
1512
+ <div style={{ color: t.muted, fontSize: 12, marginTop: 8 }}>
1513
+ {course.completedAt
1514
+ ? tr("forge.account_dashboard.courses_completed")
1515
+ : course.currentLessonId
1516
+ ? tr("forge.account_dashboard.courses_continue")
1517
+ : tr("forge.account_dashboard.courses_start")}
1518
+ </div>
1519
+ </button>
1520
+ ))}
1521
+ </div>
1522
+ )}
1523
+ </div>
1524
+ );
1525
+ }
1526
+
1360
1527
  // ---- Orders tab -------------------------------------------------------------
1361
1528
 
1362
1529
  function OrdersTab({ ctx }: { ctx: TabContext }) {
@@ -21,8 +21,9 @@ export interface AiAgentWidgetProps {
21
21
  * own config for the site's profile, renders a fixed-position bubble + panel,
22
22
  * and streams answers grounded in the artist's content + knowledge base. Render
23
23
  * it once (e.g. in __root); it renders nothing when the agent is disabled for
24
- * the profile. Appearance-only props let authors restyle it without touching
25
- * behavior. For custom UI, use the `useAiAgent()` hook instead.
24
+ * the profile, or when the visitor is on a page the artist excluded.
25
+ * Appearance-only props let authors restyle it without touching behavior. For
26
+ * custom UI, use the `useAiAgent()` hook instead.
26
27
  */
27
28
  export function AiAgentWidget({ position = "bottom-right", accentColor, title, zIndex = 9999 }: AiAgentWidgetProps) {
28
29
  const t = useForgeT();