@tribe-nest/forge 3.53.0 → 3.57.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 (74) 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/useCoachingProducts.ts +15 -1
  7. package/src/data/queries/useCourses.ts +15 -1
  8. package/src/data/queries/useEvents.ts +26 -1
  9. package/src/data/queries/useMembership.ts +176 -4
  10. package/src/data/queries/useMyTickets.ts +49 -0
  11. package/src/data/queries/usePaymentFlow.ts +8 -0
  12. package/src/data/queries/useProducts.ts +15 -1
  13. package/src/data/queries/useSubscriptions.ts +21 -1
  14. package/src/i18n/de.json +116 -1
  15. package/src/i18n/en.json +116 -1
  16. package/src/i18n/index.ts +3 -2
  17. package/src/index.ts +22 -0
  18. package/src/provider/ForgeAppProvider.tsx +8 -1
  19. package/src/provider/ForgeProvider.tsx +3 -2
  20. package/src/provider/SiteConfigProvider.tsx +3 -2
  21. package/src/runtime/RemotePage.tsx +110 -0
  22. package/src/runtime/hostRuntime.ts +84 -0
  23. package/src/runtime/pages.spec.ts +41 -0
  24. package/src/runtime/pages.ts +183 -0
  25. package/src/runtime/pagesClient.ts +127 -0
  26. package/src/runtime/registry.spec.ts +45 -0
  27. package/src/runtime/registry.ts +102 -0
  28. package/src/server/index.ts +74 -0
  29. package/src/server/jobs.ts +4 -1
  30. package/src/server/platformEvents.generated.ts +152 -0
  31. package/src/types/models.ts +185 -0
  32. package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
  33. package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
  34. package/src/ui/headless/event/ticketApproval.ts +109 -0
  35. package/src/ui/headless/event/useEventCheckout.ts +74 -4
  36. package/src/ui/headless/index.ts +34 -0
  37. package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
  38. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
  39. package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
  40. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
  41. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
  42. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
  43. package/src/ui/headless/membership/membershipApplication.ts +109 -0
  44. package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
  45. package/src/ui/headless/membership/membershipTrial.ts +53 -0
  46. package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
  47. package/src/ui/index.ts +27 -0
  48. package/src/ui/payment/ForgePaymentProvider.tsx +29 -0
  49. package/src/ui/payment/ForgeStripePayment.tsx +110 -5
  50. package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
  51. package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
  52. package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
  53. package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
  54. package/src/ui/payment/stripeIntentKind.ts +32 -0
  55. package/src/ui/shell/TribeNestApp.tsx +6 -0
  56. package/src/ui/styled/AccountDashboard.tsx +239 -15
  57. package/src/ui/styled/DocumentSigningPage.tsx +516 -0
  58. package/src/ui/styled/EventConfirmation.tsx +106 -9
  59. package/src/ui/styled/EventTickets.tsx +107 -13
  60. package/src/ui/styled/LoginForm.tsx +5 -3
  61. package/src/ui/styled/MembershipCheckout.tsx +596 -256
  62. package/src/ui/styled/MembershipTierCallout.tsx +17 -3
  63. package/src/ui/styled/MembershipTiers.tsx +148 -6
  64. package/src/ui/styled/ProductGrid.tsx +14 -2
  65. package/src/ui/styled/ReviewRequestPage.tsx +214 -0
  66. package/src/ui/styled/SignupForm.tsx +4 -2
  67. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
  68. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
  69. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
  70. package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
  71. package/src/ui/styled/forge-utilities.css +310 -0
  72. package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
  73. package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
  74. package/src/utils/ticketOrderOutcome.ts +57 -1
@@ -0,0 +1,101 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ import type { ReactNode } from "react";
4
+ import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
5
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
6
+ import { ForgeStripePayment } from "../ForgeStripePayment";
7
+
8
+ /**
9
+ * The built-in Stripe renderer routes on the SECRET: a SetupIntent secret is
10
+ * confirmed with `confirmSetup` whatever started it. A free trial on a paid
11
+ * membership reads "Start free trial" with the first-charge consent line under
12
+ * it; an application keeps its own copy; a PaymentIntent still pays.
13
+ */
14
+
15
+ const stripe = vi.hoisted(() => ({
16
+ confirmSetup: vi.fn(async (_args: unknown) => ({ setupIntent: { status: "succeeded" } }) as Record<string, unknown>),
17
+ confirmPayment: vi.fn(async (_args: unknown) => ({ paymentIntent: { status: "succeeded" } }) as Record<string, unknown>),
18
+ }));
19
+
20
+ vi.mock("@stripe/stripe-js", () => ({ loadStripe: () => Promise.resolve({}) }));
21
+ vi.mock("@stripe/react-stripe-js", () => ({
22
+ Elements: ({ children }: { children: ReactNode }) => <>{children}</>,
23
+ PaymentElement: () => null,
24
+ useStripe: () => stripe,
25
+ useElements: () => ({}),
26
+ }));
27
+ vi.mock("../../../data/queries/useWebsite", async (importOriginal) => ({
28
+ ...(await importOriginal<typeof import("../../../data/queries/useWebsite")>()),
29
+ useSiteConfig: () => ({ data: { stripePublishableKey: "pk_test", stripeAccountId: "acct_1", currency: "USD" } }),
30
+ }));
31
+
32
+ function mount(props: Partial<Parameters<typeof ForgeStripePayment>[0]>) {
33
+ const onSucceeded = vi.fn();
34
+ render(
35
+ <PublicAuthContext.Provider value={{ user: null, currencies: null, userSelectedCurrency: "USD" } as never}>
36
+ <ForgeStripePayment
37
+ clientSecret="seti_1_secret_2"
38
+ returnUrl="https://site.test/i/account?tab=membership&confirmSubscription=true"
39
+ amount={12}
40
+ currency="USD"
41
+ mode="manual"
42
+ onSucceeded={onSucceeded}
43
+ {...props}
44
+ />
45
+ </PublicAuthContext.Provider>,
46
+ );
47
+ return { onSucceeded };
48
+ }
49
+
50
+ beforeEach(() => {
51
+ stripe.confirmSetup.mockClear();
52
+ stripe.confirmPayment.mockClear();
53
+ });
54
+ afterEach(() => cleanup());
55
+
56
+ describe("ForgeStripePayment: a free trial's SetupIntent", () => {
57
+ it("reads Start free trial, shows the consent line, and confirms with confirmSetup", async () => {
58
+ const { onSucceeded } = mount({ trial: { endsAt: "2026-10-01T12:00:00.000Z", billingCycle: "month" } });
59
+
60
+ const button = await screen.findByRole("button", { name: "Start free trial" });
61
+ const notice = screen.getByTestId("trial-consent-notice");
62
+ expect(notice.textContent).toMatch(
63
+ /^Nothing is charged today\. .*12.* per month from Oct 1, 2026\. Cancel before then and you will not be charged\.$/,
64
+ );
65
+ expect(screen.queryByTestId("setup-consent-notice")).toBeNull();
66
+
67
+ fireEvent.click(button);
68
+ await waitFor(() => expect(onSucceeded).toHaveBeenCalledTimes(1));
69
+ expect(stripe.confirmSetup).toHaveBeenCalledTimes(1);
70
+ expect(stripe.confirmSetup.mock.calls[0][0]).toMatchObject({
71
+ confirmParams: { return_url: "https://site.test/i/account?tab=membership&confirmSubscription=true" },
72
+ redirect: "if_required",
73
+ });
74
+ expect(stripe.confirmPayment).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it("names a yearly cycle", async () => {
78
+ mount({ trial: { endsAt: "2026-10-01T12:00:00.000Z", billingCycle: "year" } });
79
+ expect((await screen.findByTestId("trial-consent-notice")).textContent).toContain(" per year from ");
80
+ });
81
+
82
+ it("keeps the application copy for a SetupIntent with no trial", async () => {
83
+ mount({ trial: null });
84
+ expect(await screen.findByRole("button", { name: "Submit application" })).toBeTruthy();
85
+ expect(screen.getByTestId("setup-consent-notice")).toBeTruthy();
86
+ expect(screen.queryByTestId("trial-consent-notice")).toBeNull();
87
+ });
88
+
89
+ it("ignores trial on a PaymentIntent and pays", async () => {
90
+ const { onSucceeded } = mount({
91
+ clientSecret: "pi_1_secret_2",
92
+ trial: { endsAt: "2026-10-01T12:00:00.000Z", billingCycle: "month" },
93
+ });
94
+ expect(screen.queryByTestId("trial-consent-notice")).toBeNull();
95
+ const button = await screen.findByRole("button", { name: /^Pay / });
96
+ fireEvent.click(button);
97
+ await waitFor(() => expect(onSucceeded).toHaveBeenCalledTimes(1));
98
+ expect(stripe.confirmPayment).toHaveBeenCalledTimes(1);
99
+ expect(stripe.confirmSetup).not.toHaveBeenCalled();
100
+ });
101
+ });
@@ -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,43 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { isSetupConfirmSuccess, SETUP_CONFIRM_SUCCESS_STATUSES, stripeIntentKind } from "../stripeIntentKind";
3
+
4
+ /**
5
+ * An application to a membership tier saves the card (a SetupIntent) instead of
6
+ * charging it. The renderer routes on the secret, and confirming a setup secret
7
+ * through `confirmPayment` fails with an error the fan cannot act on, so the
8
+ * routing is a pure function pinned here.
9
+ */
10
+ describe("stripeIntentKind", () => {
11
+ it("routes a SetupIntent secret to the setup confirm", () => {
12
+ expect(stripeIntentKind("seti_1AbC_secret_xyz")).toBe("setup");
13
+ });
14
+
15
+ it("keeps every other secret on the payment confirm", () => {
16
+ expect(stripeIntentKind("pi_1AbC_secret_xyz")).toBe("payment");
17
+ // Only the prefix counts, never a substring.
18
+ expect(stripeIntentKind("pi_seti_secret")).toBe("payment");
19
+ expect(stripeIntentKind("")).toBe("payment");
20
+ expect(stripeIntentKind(undefined)).toBe("payment");
21
+ expect(stripeIntentKind(null)).toBe("payment");
22
+ });
23
+ });
24
+
25
+ describe("isSetupConfirmSuccess", () => {
26
+ it("counts a saved card, and a delayed method still processing, as the fan being done", () => {
27
+ expect(isSetupConfirmSuccess("succeeded")).toBe(true);
28
+ expect(isSetupConfirmSuccess("processing")).toBe(true);
29
+ });
30
+
31
+ it("refuses every status that means the card is not saved", () => {
32
+ for (const status of ["requires_payment_method", "requires_confirmation", "requires_action", "canceled"]) {
33
+ expect(isSetupConfirmSuccess(status)).toBe(false);
34
+ }
35
+ expect(isSetupConfirmSuccess(undefined)).toBe(false);
36
+ expect(isSetupConfirmSuccess(null)).toBe(false);
37
+ expect(isSetupConfirmSuccess("")).toBe(false);
38
+ });
39
+
40
+ it("exposes the set the renderer reads", () => {
41
+ expect([...SETUP_CONFIRM_SUCCESS_STATUSES].sort()).toEqual(["processing", "succeeded"]);
42
+ });
43
+ });
@@ -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
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Which kind of Stripe intent a client secret belongs to.
3
+ *
4
+ * A PaymentIntent secret (`pi_..._secret_...`) takes money now; a SetupIntent
5
+ * secret (`seti_..._secret_...`) only saves the card for a later charge, which
6
+ * is what an application to a membership tier does: nothing is charged unless
7
+ * the business approves. The two confirm through different Stripe calls
8
+ * (`confirmPayment` vs `confirmSetup`), and calling the wrong one fails with an
9
+ * error the fan cannot act on, so the renderer routes on the secret itself.
10
+ */
11
+ export type StripeIntentKind = "payment" | "setup";
12
+
13
+ export function stripeIntentKind(clientSecret: string | null | undefined): StripeIntentKind {
14
+ return typeof clientSecret === "string" && clientSecret.startsWith("seti_") ? "setup" : "payment";
15
+ }
16
+
17
+ /**
18
+ * The SetupIntent statuses `confirmSetup` can leave behind that mean "the fan's
19
+ * part is done".
20
+ *
21
+ * succeeded the card is saved
22
+ * processing a delayed method; the provider finishes it and the webhook reports it
23
+ *
24
+ * Anything else (`requires_payment_method`, an unresolved `requires_action`,
25
+ * `canceled`) is not done.
26
+ */
27
+ export const SETUP_CONFIRM_SUCCESS_STATUSES: ReadonlySet<string> = new Set(["succeeded", "processing"]);
28
+
29
+ /** Does this post-confirm SetupIntent status count as the fan having finished? */
30
+ export function isSetupConfirmSuccess(status: string | null | undefined): boolean {
31
+ return !!status && SETUP_CONFIRM_SUCCESS_STATUSES.has(status);
32
+ }
@@ -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,
@@ -23,6 +25,11 @@ import {
23
25
  type MyCourse,
24
26
  useCancelMembership,
25
27
  useMembershipAccess,
28
+ useMyMembershipApplications,
29
+ useWithdrawMembershipApplication,
30
+ useConfirmMembershipApplication,
31
+ type MembershipApplication,
32
+ type MembershipApplicationStatus,
26
33
  useOpenBillingPortal,
27
34
  useUpdateAccount,
28
35
  useChangePassword,
@@ -45,6 +52,8 @@ import { WalletPassButtons } from "./WalletPassButtons";
45
52
  import { TicketTransferPanel } from "./TicketTransfer";
46
53
  import { CartLineOptions } from "./CartLineOptions";
47
54
  import { FilmRentalList } from "./FilmLibrary";
55
+ import { canWithdrawMembershipApplication } from "../headless/membership/membershipApplication";
56
+ import { isMembershipInTrial } from "../headless/membership/membershipTrial";
48
57
 
49
58
  export const ACCOUNT_TABS = [
50
59
  "membership",
@@ -365,6 +374,14 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
365
374
  const [cancelError, setCancelError] = useState<string | null>(null);
366
375
 
367
376
  const membership = user?.membership;
377
+ /**
378
+ * Inside a free trial: nothing has been charged yet. The date the first
379
+ * charge falls and what it will be are state the member acts on (cancel
380
+ * before then, or not), so they are visible, not behind a hint.
381
+ */
382
+ const inTrial = isMembershipInTrial(membership);
383
+ const cycleWord = (cycle: string | undefined) =>
384
+ cycle === "year" ? tr("forge.account_dashboard.cycle_year") : tr("forge.account_dashboard.cycle_month");
368
385
  // Two separate questions, deliberately answered separately:
369
386
  // `access.hasAccess` → are the benefits live (true through past_due/grace)
370
387
  // `message` → what the member is told, including a failed payment
@@ -445,7 +462,25 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
445
462
  ))}
446
463
  </ul>
447
464
  )}
448
- {membership.endDate && (
465
+ {inTrial && membership.trialEndsAt && (
466
+ <div data-testid="membership-trial-state">
467
+ <p style={{ fontSize: 14, fontWeight: 600 }}>
468
+ {tr("forge.account_dashboard.membership_trial_until", { date: formatDate(membership.trialEndsAt) })}
469
+ </p>
470
+ <p style={{ fontSize: 14, opacity: 0.85, marginTop: 2 }}>
471
+ {access.cancelAtPeriodEnd
472
+ ? tr("forge.account_dashboard.membership_trial_cancelled")
473
+ : membership.subscriptionAmount
474
+ ? tr("forge.account_dashboard.membership_trial_then", {
475
+ amount: formatAmount(membership.subscriptionAmount, membership.subscriptionCurrency || currency),
476
+ cycle: cycleWord(membership.billingCycle),
477
+ date: formatDate(membership.trialEndsAt),
478
+ })
479
+ : null}
480
+ </p>
481
+ </div>
482
+ )}
483
+ {membership.endDate && !inTrial && (
449
484
  <p style={{ fontSize: 14, opacity: 0.8 }}>
450
485
  {access.billingState === "active"
451
486
  ? tr("forge.account_dashboard.membership_renews_on", { date: formatDate(membership.endDate) })
@@ -502,7 +537,9 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
502
537
  <div role="alertdialog" aria-modal="true" style={{ ...card, marginTop: 16 }}>
503
538
  <p style={{ fontWeight: 700 }}>{tr("forge.account_dashboard.cancel_confirm_title")}</p>
504
539
  <p style={{ fontSize: 14, opacity: 0.85, marginTop: 8 }}>
505
- {membership?.subscriptionAmount
540
+ {inTrial && membership?.trialEndsAt
541
+ ? tr("forge.account_dashboard.cancel_confirm_trial", { date: formatDate(membership.trialEndsAt) })
542
+ : membership?.subscriptionAmount
506
543
  ? membership.endDate
507
544
  ? tr("forge.account_dashboard.cancel_confirm_paid_until", { date: formatDate(membership.endDate) })
508
545
  : tr("forge.account_dashboard.cancel_confirm_paid")
@@ -528,6 +565,129 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
528
565
  </div>
529
566
  </div>
530
567
  )}
568
+
569
+ <MembershipApplicationsSection ctx={ctx} />
570
+ </div>
571
+ );
572
+ }
573
+
574
+ const APPLICATION_STATUS_LABEL: Record<MembershipApplicationStatus, string> = {
575
+ card_pending: "forge.account_dashboard.application_status_card_pending",
576
+ pending: "forge.account_dashboard.application_status_pending",
577
+ approved: "forge.account_dashboard.application_status_approved",
578
+ payment_failed: "forge.account_dashboard.application_status_payment_failed",
579
+ joined: "forge.account_dashboard.application_status_joined",
580
+ declined: "forge.account_dashboard.application_status_declined",
581
+ withdrawn: "forge.account_dashboard.application_status_withdrawn",
582
+ expired: "forge.account_dashboard.application_status_expired",
583
+ };
584
+
585
+ /**
586
+ * The fan's applications to membership tiers, with where each stands, so a fan
587
+ * who lost the email still knows. Renders nothing when there are none.
588
+ *
589
+ * A card that needed a bank redirect comes back here with `?confirmApplication=`:
590
+ * that application is confirmed once on arrival (idempotent on the server, and
591
+ * the provider's webhook reports it too, so this only makes the list current
592
+ * sooner).
593
+ */
594
+ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
595
+ const { user } = usePublicAuth();
596
+ const tr = useForgeT();
597
+ const { t, ghostButton, button } = useCardStyles();
598
+ const { formatAmount, formatDate, currency } = ctx;
599
+ const { data: applications } = useMyMembershipApplications(user?.id);
600
+ const withdraw = useWithdrawMembershipApplication();
601
+ const confirm = useConfirmMembershipApplication();
602
+ const [error, setError] = useState<string | null>(null);
603
+
604
+ useEffect(() => {
605
+ if (typeof window === "undefined") return;
606
+ const id = new URLSearchParams(window.location.search).get("confirmApplication");
607
+ if (!id || !user?.id) return;
608
+ confirm.mutate({ applicationId: id });
609
+ // Once per arrival.
610
+ // eslint-disable-next-line react-hooks/exhaustive-deps
611
+ }, [user?.id]);
612
+
613
+ if (!applications?.length) return null;
614
+
615
+ const onWithdraw = async (application: MembershipApplication) => {
616
+ setError(null);
617
+ try {
618
+ await withdraw.mutateAsync({ applicationId: application.id });
619
+ } catch (e) {
620
+ const detail = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
621
+ setError(detail || tr("forge.account_dashboard.application_withdraw_error"));
622
+ }
623
+ };
624
+
625
+ return (
626
+ <div style={{ marginTop: 24, paddingTop: 16, borderTop: `1px solid ${t.primary}20` }}>
627
+ <h3 style={{ fontWeight: 700, marginBottom: 12, fontFamily: t.headingFontFamily }}>
628
+ {tr("forge.account_dashboard.applications_title")}
629
+ </h3>
630
+ <ul
631
+ data-testid="membership-applications"
632
+ style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 12 }}
633
+ >
634
+ {applications.map((application) => (
635
+ <li
636
+ key={application.id}
637
+ style={{ display: "flex", gap: 12, justifyContent: "space-between", alignItems: "center", flexWrap: "wrap" }}
638
+ >
639
+ <div style={{ minWidth: 0 }}>
640
+ <p style={{ fontWeight: 600 }}>{application.membershipTierName ?? ""}</p>
641
+ <p style={{ fontSize: 13, opacity: 0.85 }}>{tr(APPLICATION_STATUS_LABEL[application.status])}</p>
642
+ {application.amount != null && application.billingCycle && (
643
+ <p style={{ fontSize: 13, opacity: 0.8 }}>
644
+ {tr("forge.account_dashboard.membership_price", {
645
+ amount: formatAmount(application.amount, application.currency || currency),
646
+ cycle: application.billingCycle,
647
+ })}
648
+ </p>
649
+ )}
650
+ {application.status === "payment_failed" && application.paymentWindowExpiresAt && (
651
+ <p style={{ fontSize: 13, fontWeight: 600 }}>
652
+ {tr("forge.account_dashboard.application_complete_by", {
653
+ date: formatDate(application.paymentWindowExpiresAt),
654
+ })}
655
+ </p>
656
+ )}
657
+ {application.status === "declined" && application.decisionNote && (
658
+ <p style={{ fontSize: 13, opacity: 0.8, maxWidth: 480 }}>{application.decisionNote}</p>
659
+ )}
660
+ <p style={{ fontSize: 12, opacity: 0.65 }}>
661
+ {tr("forge.account_dashboard.application_submitted_on", { date: formatDate(application.createdAt) })}
662
+ </p>
663
+ </div>
664
+ {canWithdrawMembershipApplication(application.status) && (
665
+ <button
666
+ onClick={() => void onWithdraw(application)}
667
+ disabled={withdraw.isPending}
668
+ style={ghostButton}
669
+ >
670
+ {withdraw.isPending && withdraw.variables?.applicationId === application.id
671
+ ? tr("forge.account_dashboard.application_withdrawing")
672
+ : tr("forge.account_dashboard.application_withdraw")}
673
+ </button>
674
+ )}
675
+ {application.status === "payment_failed" && (
676
+ <a
677
+ href={`/i/membership/checkout?membershipTierId=${encodeURIComponent(application.membershipTierId)}`}
678
+ style={{ ...button, textDecoration: "none" }}
679
+ >
680
+ {tr("forge.account_dashboard.application_complete")}
681
+ </a>
682
+ )}
683
+ </li>
684
+ ))}
685
+ </ul>
686
+ {error && (
687
+ <p role="alert" style={{ fontSize: 13, marginTop: 8, color: t.primary }}>
688
+ {error}
689
+ </p>
690
+ )}
531
691
  </div>
532
692
  );
533
693
  }
@@ -599,17 +759,47 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
599
759
  const cancelled = !!ticket.selfCancelledAt;
600
760
  // Absent on an older API build, where every row WAS a purchase.
601
761
  const gifted = ticket.role === "holder";
762
+ /**
763
+ * A ticket REQUEST (curated access, feature 3). While it is pending,
764
+ * or once it ended without a sale, the row is a request and not a
765
+ * purchase: no passes exist, so nothing to cancel, transfer or add
766
+ * to a wallet, and the state line is the whole story. The "Decide
767
+ * by" date is the same one the fan's email carries, so somebody who
768
+ * lost the email still knows where they stand.
769
+ */
770
+ const requestState = myTicketRequestState(ticket);
771
+ const isRequestRow = isMyTicketRequestOpenOrEnded(ticket);
772
+ const requestEnded = isRequestRow && requestState !== "pending";
602
773
  return (
603
774
  <div
604
775
  key={ticket.id}
605
- style={{ border: `1px solid ${t.primary}20`, borderRadius: t.cornerRadius, padding: 16, opacity: cancelled ? 0.6 : 1 }}
776
+ data-testid={requestState ? `my-ticket-request-${requestState}` : undefined}
777
+ style={{
778
+ border: `1px solid ${t.primary}20`,
779
+ borderRadius: t.cornerRadius,
780
+ padding: 16,
781
+ opacity: cancelled || requestEnded ? 0.6 : 1,
782
+ }}
606
783
  >
607
784
  <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12, gap: 12 }}>
608
785
  <div>
609
786
  <div style={{ fontWeight: 600 }}>{ticket.eventTitle}</div>
610
787
  <div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(ticket.eventDateTime)}</div>
611
788
  </div>
612
- {cancelled && (
789
+ {requestState && (
790
+ <span style={{ fontSize: 13, color: t.primary, textAlign: "right" }}>
791
+ {requestState === "pending"
792
+ ? tr("forge.account_dashboard.request_pending")
793
+ : requestState === "approved"
794
+ ? tr("forge.account_dashboard.request_approved")
795
+ : requestState === "declined"
796
+ ? tr("forge.account_dashboard.request_declined")
797
+ : requestState === "expired"
798
+ ? tr("forge.account_dashboard.request_expired")
799
+ : tr("forge.account_dashboard.request_payment_failed")}
800
+ </span>
801
+ )}
802
+ {cancelled && !requestState && (
613
803
  <span style={{ fontSize: 13, color: t.primary }}>{tr("forge.account_dashboard.cancelled")}</span>
614
804
  )}
615
805
  {/* Says WHY this is in the list at all. Without it a
@@ -641,23 +831,57 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
641
831
 
642
832
  {!gifted && (
643
833
  <div style={{ display: "flex", justifyContent: "space-between", fontWeight: 700, marginTop: 12, paddingTop: 12, borderTop: `1px solid ${t.primary}20` }}>
644
- <span>{tr("forge.account_dashboard.total")}</span>
834
+ {/* Nothing has been taken on a pending request, and never
835
+ will be on one that ended: the label says which. */}
836
+ <span>
837
+ {requestState === "pending"
838
+ ? tr("forge.account_dashboard.total_on_approval")
839
+ : tr("forge.account_dashboard.total")}
840
+ </span>
645
841
  <span style={{ color: t.primary }}>{formatAmount(Number(ticket.totalAmount), ticket.currency || currency)}</span>
646
842
  </div>
647
843
  )}
648
844
 
845
+ {/* State the fan must act on (or wait on) stays visible: the
846
+ deadline while pending, the organiser's note when declined,
847
+ and where to go when the seat was released. */}
848
+ {requestState === "pending" && ticket.request?.expiresAt && (
849
+ <div data-testid="my-ticket-decide-by" style={{ fontSize: 13, marginTop: 12, fontWeight: 600 }}>
850
+ {tr("forge.account_dashboard.request_decide_by", { date: formatDate(ticket.request.expiresAt) })}
851
+ </div>
852
+ )}
853
+ {requestState === "pending" && (
854
+ <div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>
855
+ {tr("forge.account_dashboard.request_nothing_charged")}
856
+ </div>
857
+ )}
858
+ {requestState === "declined" && ticket.request?.decisionNote && (
859
+ <div data-testid="my-ticket-decision-note" style={{ fontSize: 13, opacity: 0.75, marginTop: 12 }}>
860
+ {tr("forge.account_dashboard.request_note", { note: ticket.request.decisionNote })}
861
+ </div>
862
+ )}
863
+ {(requestState === "expired" || requestState === "payment_failed") && (
864
+ <div style={{ fontSize: 13, opacity: 0.75, marginTop: 12 }}>
865
+ {tr("forge.account_dashboard.request_seat_released")}
866
+ </div>
867
+ )}
868
+
649
869
  {/* Diarise it — including the Apple option, which needs the
650
870
  platform's own `.ics` and so could not exist before. Null
651
871
  for a called-off show, and the component draws nothing. */}
652
- <AddToCalendar
653
- variant="compact"
654
- title={ticket.eventTitle}
655
- start={ticket.eventDateTime}
656
- end={ticket.eventEndDateTime}
657
- icsUrl={ticket.calendarUrl}
658
- webcalUrl={ticket.calendarWebcalUrl}
659
- style={{ marginTop: 12 }}
660
- />
872
+ {/* Not offered on a request: the fan has not been admitted to
873
+ anything yet, and a calendar entry says they have. */}
874
+ {!isRequestRow && (
875
+ <AddToCalendar
876
+ variant="compact"
877
+ title={ticket.eventTitle}
878
+ start={ticket.eventDateTime}
879
+ end={ticket.eventEndDateTime}
880
+ icsUrl={ticket.calendarUrl}
881
+ webcalUrl={ticket.calendarWebcalUrl}
882
+ style={{ marginTop: 12 }}
883
+ />
884
+ )}
661
885
 
662
886
  {/*
663
887
  Wallet passes (2.3). Renders NOTHING unless the API offers a
@@ -705,7 +929,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
705
929
  <div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>{ticket.cancellation.terms}</div>
706
930
  )}
707
931
 
708
- {!cancelled && ticket.cancellation.canCancel && (
932
+ {!cancelled && !isRequestRow && ticket.cancellation.canCancel && (
709
933
  <button
710
934
  onClick={() => void onCancel(ticket)}
711
935
  disabled={pendingId === ticket.id}