@tribe-nest/forge 3.54.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 (40) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/useCoachingProducts.ts +15 -1
  3. package/src/data/queries/useCourses.ts +15 -1
  4. package/src/data/queries/useEvents.ts +12 -1
  5. package/src/data/queries/useMembership.ts +176 -4
  6. package/src/data/queries/useProducts.ts +15 -1
  7. package/src/data/queries/useSubscriptions.ts +21 -1
  8. package/src/i18n/de.json +74 -0
  9. package/src/i18n/en.json +74 -0
  10. package/src/server/index.ts +74 -0
  11. package/src/server/platformEvents.generated.ts +60 -0
  12. package/src/types/models.ts +127 -0
  13. package/src/ui/headless/index.ts +23 -0
  14. package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
  15. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
  16. package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
  17. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
  18. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
  19. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
  20. package/src/ui/headless/membership/membershipApplication.ts +109 -0
  21. package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
  22. package/src/ui/headless/membership/membershipTrial.ts +53 -0
  23. package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
  24. package/src/ui/index.ts +9 -0
  25. package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
  26. package/src/ui/payment/ForgeStripePayment.tsx +78 -7
  27. package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
  28. package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
  29. package/src/ui/payment/stripeIntentKind.ts +32 -0
  30. package/src/ui/styled/AccountDashboard.tsx +160 -2
  31. package/src/ui/styled/LoginForm.tsx +5 -3
  32. package/src/ui/styled/MembershipCheckout.tsx +596 -256
  33. package/src/ui/styled/MembershipTierCallout.tsx +17 -3
  34. package/src/ui/styled/MembershipTiers.tsx +148 -6
  35. package/src/ui/styled/ProductGrid.tsx +14 -2
  36. package/src/ui/styled/SignupForm.tsx +4 -2
  37. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
  38. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
  39. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
  40. package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
@@ -5,9 +5,11 @@ 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
+ import { useForgeLocale, useForgeT } from "../../i18n";
9
+ import { formatTrialDate } from "../headless/membership/membershipTrial";
9
10
  import type { PaymentRenderProps } from "./ForgePaymentProvider";
10
11
  import { deferredChargeNotice, isDeferredSettlement, isManualConfirmSuccess } from "./stripeConfirmOutcome";
12
+ import { isSetupConfirmSuccess, stripeIntentKind, type StripeIntentKind } from "./stripeIntentKind";
11
13
 
12
14
  function alpha(hex: number) {
13
15
  return Math.round(hex * 255)
@@ -23,6 +25,13 @@ function alpha(hex: number) {
23
25
  * `useSiteConfig()` (Stripe Connect: `loadStripe(platformKey, { stripeAccount })`),
24
26
  * the appearance from `ForgeThemeProvider`, and the pay-button amount from
25
27
  * `useFormatCurrency()`. The consuming site wires nothing.
28
+ *
29
+ * A SetupIntent secret (`seti_...`) saves the card instead of charging it (an
30
+ * application to a membership tier): the same Element renders, the confirm goes
31
+ * through `stripe.confirmSetup`, and the button and the line under it say that
32
+ * the card is charged only on approval. A setup that starts a free trial on a
33
+ * paid membership (`trial` set) reads "Start free trial" and says nothing is
34
+ * charged today, then what is charged from which date.
26
35
  */
27
36
  export function ForgeStripePayment({
28
37
  clientSecret,
@@ -34,6 +43,7 @@ export function ForgeStripePayment({
34
43
  onFailed,
35
44
  settlement,
36
45
  deferredStrategy,
46
+ trial,
37
47
  }: PaymentRenderProps) {
38
48
  const theme = useForgeTheme();
39
49
  const { data: siteConfig } = useSiteConfig();
@@ -96,6 +106,7 @@ export function ForgeStripePayment({
96
106
  return (
97
107
  <Elements options={{ clientSecret, appearance, loader: "auto" }} stripe={stripePromise}>
98
108
  <Inner
109
+ intentKind={stripeIntentKind(clientSecret)}
99
110
  returnUrl={returnUrl}
100
111
  amount={amount}
101
112
  currency={currency}
@@ -104,12 +115,14 @@ export function ForgeStripePayment({
104
115
  onFailed={onFailed}
105
116
  settlement={settlement}
106
117
  deferredStrategy={deferredStrategy}
118
+ trial={trial}
107
119
  />
108
120
  </Elements>
109
121
  );
110
122
  }
111
123
 
112
124
  function Inner({
125
+ intentKind,
113
126
  returnUrl,
114
127
  amount,
115
128
  currency,
@@ -118,9 +131,11 @@ function Inner({
118
131
  onFailed,
119
132
  settlement,
120
133
  deferredStrategy,
121
- }: Omit<PaymentRenderProps, "clientSecret">) {
134
+ trial,
135
+ }: Omit<PaymentRenderProps, "clientSecret"> & { intentKind: StripeIntentKind }) {
122
136
  const theme = useForgeTheme();
123
137
  const t = useForgeT();
138
+ const locale = useForgeLocale();
124
139
  const { formatCurrency } = useFormatCurrency();
125
140
  const stripe = useStripe();
126
141
  const elements = useElements();
@@ -133,15 +148,54 @@ function Inner({
133
148
  // it has been told their card was committed and nothing about why.
134
149
  const deferred = isDeferredSettlement(settlement);
135
150
  const formattedAmount = formatCurrency(amount, currency || undefined);
136
- const buttonText = deferred
137
- ? t("forge.forge_stripe_payment.authorize", { amount: formattedAmount })
138
- : `Pay ${formattedAmount} Now`;
151
+ // A setup takes nothing now: the button submits the application, and the
152
+ // consent line under it is the thing the fan is agreeing to, so it is content.
153
+ const isSetup = intentKind === "setup";
154
+ // A trial also saves the card, but for a subscription the fan is starting now.
155
+ const isTrial = isSetup && !!trial;
156
+ const buttonText = isTrial
157
+ ? t("forge.forge_stripe_payment.start_free_trial")
158
+ : isSetup
159
+ ? t("forge.forge_stripe_payment.submit_application")
160
+ : deferred
161
+ ? t("forge.forge_stripe_payment.authorize", { amount: formattedAmount })
162
+ : `Pay ${formattedAmount} Now`;
139
163
 
140
164
  const handleSubmit = useCallback(async () => {
141
165
  if (!stripe || !elements) return;
142
166
  setIsLoading(true);
143
167
  setMessage("");
144
168
 
169
+ if (isSetup) {
170
+ if (mode === "manual") {
171
+ const { error, setupIntent } = await stripe.confirmSetup({
172
+ elements,
173
+ confirmParams: { return_url: returnUrl },
174
+ redirect: "if_required",
175
+ });
176
+ if (error) {
177
+ const msg = error.message ?? t("forge.forge_stripe_payment.setup_failed");
178
+ setMessage(msg);
179
+ setIsLoading(false);
180
+ onFailed?.(msg);
181
+ return;
182
+ }
183
+ setIsLoading(false);
184
+ if (isSetupConfirmSuccess(setupIntent?.status)) {
185
+ onSucceeded?.();
186
+ return;
187
+ }
188
+ const msg = t("forge.forge_stripe_payment.setup_incomplete");
189
+ setMessage(msg);
190
+ onFailed?.(msg);
191
+ return;
192
+ }
193
+ const { error } = await stripe.confirmSetup({ elements, confirmParams: { return_url: returnUrl } });
194
+ setMessage(error.message ?? t("forge.forge_stripe_payment.setup_failed"));
195
+ setIsLoading(false);
196
+ return;
197
+ }
198
+
145
199
  if (mode === "manual") {
146
200
  const { error, paymentIntent } = await stripe.confirmPayment({
147
201
  elements,
@@ -170,7 +224,7 @@ function Inner({
170
224
  const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: returnUrl } });
171
225
  setMessage(error.message ?? "An unexpected error occurred.");
172
226
  setIsLoading(false);
173
- }, [stripe, elements, mode, returnUrl, onSucceeded, onFailed]);
227
+ }, [stripe, elements, mode, returnUrl, onSucceeded, onFailed, isSetup, t]);
174
228
 
175
229
  return (
176
230
  <form onSubmit={(e) => e.preventDefault()} style={{ display: "flex", flexDirection: "column", gap: 12 }}>
@@ -192,7 +246,24 @@ function Inner({
192
246
  >
193
247
  {isLoading ? "Processing…" : buttonText}
194
248
  </button>
195
- {deferred && (
249
+ {isTrial && trial && (
250
+ <p data-testid="trial-consent-notice" style={{ margin: 0, fontSize: 13, opacity: 0.8, lineHeight: 1.5 }}>
251
+ {t("forge.forge_stripe_payment.trial_consent", {
252
+ amount: formattedAmount,
253
+ cycle:
254
+ trial.billingCycle === "year"
255
+ ? t("forge.forge_stripe_payment.cycle_year")
256
+ : t("forge.forge_stripe_payment.cycle_month"),
257
+ date: formatTrialDate(trial.endsAt, locale),
258
+ })}
259
+ </p>
260
+ )}
261
+ {isSetup && !isTrial && (
262
+ <p data-testid="setup-consent-notice" style={{ margin: 0, fontSize: 13, opacity: 0.8, lineHeight: 1.5 }}>
263
+ {t("forge.forge_stripe_payment.setup_charged_on_approval", { amount: formattedAmount })}
264
+ </p>
265
+ )}
266
+ {!isSetup && deferred && (
196
267
  <p data-testid="deferred-charge-notice" style={{ margin: 0, fontSize: 13, opacity: 0.8, lineHeight: 1.5 }}>
197
268
  {t("forge.forge_stripe_payment.deferred_charged_on_approval")}
198
269
  {deferredChargeNotice(deferredStrategy) === "authorization" && (
@@ -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,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,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
+ }
@@ -25,6 +25,11 @@ import {
25
25
  type MyCourse,
26
26
  useCancelMembership,
27
27
  useMembershipAccess,
28
+ useMyMembershipApplications,
29
+ useWithdrawMembershipApplication,
30
+ useConfirmMembershipApplication,
31
+ type MembershipApplication,
32
+ type MembershipApplicationStatus,
28
33
  useOpenBillingPortal,
29
34
  useUpdateAccount,
30
35
  useChangePassword,
@@ -47,6 +52,8 @@ import { WalletPassButtons } from "./WalletPassButtons";
47
52
  import { TicketTransferPanel } from "./TicketTransfer";
48
53
  import { CartLineOptions } from "./CartLineOptions";
49
54
  import { FilmRentalList } from "./FilmLibrary";
55
+ import { canWithdrawMembershipApplication } from "../headless/membership/membershipApplication";
56
+ import { isMembershipInTrial } from "../headless/membership/membershipTrial";
50
57
 
51
58
  export const ACCOUNT_TABS = [
52
59
  "membership",
@@ -367,6 +374,14 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
367
374
  const [cancelError, setCancelError] = useState<string | null>(null);
368
375
 
369
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");
370
385
  // Two separate questions, deliberately answered separately:
371
386
  // `access.hasAccess` → are the benefits live (true through past_due/grace)
372
387
  // `message` → what the member is told, including a failed payment
@@ -447,7 +462,25 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
447
462
  ))}
448
463
  </ul>
449
464
  )}
450
- {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 && (
451
484
  <p style={{ fontSize: 14, opacity: 0.8 }}>
452
485
  {access.billingState === "active"
453
486
  ? tr("forge.account_dashboard.membership_renews_on", { date: formatDate(membership.endDate) })
@@ -504,7 +537,9 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
504
537
  <div role="alertdialog" aria-modal="true" style={{ ...card, marginTop: 16 }}>
505
538
  <p style={{ fontWeight: 700 }}>{tr("forge.account_dashboard.cancel_confirm_title")}</p>
506
539
  <p style={{ fontSize: 14, opacity: 0.85, marginTop: 8 }}>
507
- {membership?.subscriptionAmount
540
+ {inTrial && membership?.trialEndsAt
541
+ ? tr("forge.account_dashboard.cancel_confirm_trial", { date: formatDate(membership.trialEndsAt) })
542
+ : membership?.subscriptionAmount
508
543
  ? membership.endDate
509
544
  ? tr("forge.account_dashboard.cancel_confirm_paid_until", { date: formatDate(membership.endDate) })
510
545
  : tr("forge.account_dashboard.cancel_confirm_paid")
@@ -530,6 +565,129 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
530
565
  </div>
531
566
  </div>
532
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
+ )}
533
691
  </div>
534
692
  );
535
693
  }
@@ -17,10 +17,12 @@ export interface LoginFormProps {
17
17
  * form looks exactly as it did.
18
18
  */
19
19
  membershipTierId?: string;
20
+ /** The membership invite token riding on the same redirect, if any. See `MembershipTierCallout`. */
21
+ membershipInviteToken?: string;
20
22
  }
21
23
 
22
24
  /** Themed 2FA login form (credentials → emailed code), built on `useLoginFlow`. */
23
- export function LoginForm({ onSuccess, signupHref, forgotHref, membershipTierId }: LoginFormProps) {
25
+ export function LoginForm({ onSuccess, signupHref, forgotHref, membershipTierId, membershipInviteToken }: LoginFormProps) {
24
26
  const t = useForgeT();
25
27
  const theme = useForgeTheme();
26
28
  const flow = useLoginFlow({ onSuccess });
@@ -70,7 +72,7 @@ export function LoginForm({ onSuccess, signupHref, forgotHref, membershipTierId
70
72
  <div style={card}>
71
73
  {/* Still on the way to the tier they picked, so it stays in view while
72
74
  they fetch the code out of their inbox. */}
73
- <MembershipTierCallout membershipTierId={membershipTierId} />
75
+ <MembershipTierCallout membershipTierId={membershipTierId} inviteToken={membershipInviteToken} />
74
76
  <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>{t("forge.login_form.verify_title")}</h1>
75
77
  <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 24 }}>
76
78
  {t("forge.login_form.code_sent_to")} <strong>{flow.email}</strong>
@@ -118,7 +120,7 @@ export function LoginForm({ onSuccess, signupHref, forgotHref, membershipTierId
118
120
  <div style={card}>
119
121
  {/* What the fan was doing when they hit this wall. Nothing is drawn when
120
122
  they came here on their own. */}
121
- <MembershipTierCallout membershipTierId={membershipTierId} />
123
+ <MembershipTierCallout membershipTierId={membershipTierId} inviteToken={membershipInviteToken} />
122
124
  <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>{t("forge.login_form.title")}</h1>
123
125
  {flow.error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{flow.error}</p>}
124
126
  <form