@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.
- package/package.json +1 -1
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +12 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +74 -0
- package/src/i18n/en.json +74 -0
- package/src/server/index.ts +74 -0
- package/src/server/platformEvents.generated.ts +60 -0
- package/src/types/models.ts +127 -0
- package/src/ui/headless/index.ts +23 -0
- package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
- package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
- package/src/ui/headless/membership/membershipApplication.ts +109 -0
- package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
- package/src/ui/headless/membership/membershipTrial.ts +53 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
- package/src/ui/index.ts +9 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
- package/src/ui/payment/ForgeStripePayment.tsx +78 -7
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/styled/AccountDashboard.tsx +160 -2
- package/src/ui/styled/LoginForm.tsx +5 -3
- package/src/ui/styled/MembershipCheckout.tsx +596 -256
- package/src/ui/styled/MembershipTierCallout.tsx +17 -3
- package/src/ui/styled/MembershipTiers.tsx +148 -6
- package/src/ui/styled/ProductGrid.tsx +14 -2
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
- 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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
{
|
|
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.
|
|
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?.
|
|
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
|