@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.
- package/package.json +1 -1
- package/src/contexts/AppAuthContext.tsx +3 -2
- package/src/contexts/AudioPlayerContext.tsx +3 -2
- package/src/contexts/CartContext.tsx +3 -2
- package/src/contexts/PublicAuthContext.tsx +3 -2
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +26 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useMyTickets.ts +49 -0
- package/src/data/queries/usePaymentFlow.ts +8 -0
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +116 -1
- package/src/i18n/en.json +116 -1
- package/src/i18n/index.ts +3 -2
- package/src/index.ts +22 -0
- package/src/provider/ForgeAppProvider.tsx +8 -1
- package/src/provider/ForgeProvider.tsx +3 -2
- package/src/provider/SiteConfigProvider.tsx +3 -2
- package/src/runtime/RemotePage.tsx +110 -0
- package/src/runtime/hostRuntime.ts +84 -0
- package/src/runtime/pages.spec.ts +41 -0
- package/src/runtime/pages.ts +183 -0
- package/src/runtime/pagesClient.ts +127 -0
- package/src/runtime/registry.spec.ts +45 -0
- package/src/runtime/registry.ts +102 -0
- package/src/server/index.ts +74 -0
- package/src/server/jobs.ts +4 -1
- package/src/server/platformEvents.generated.ts +152 -0
- package/src/types/models.ts +185 -0
- package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
- package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
- package/src/ui/headless/event/ticketApproval.ts +109 -0
- package/src/ui/headless/event/useEventCheckout.ts +74 -4
- package/src/ui/headless/index.ts +34 -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 +27 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +29 -0
- package/src/ui/payment/ForgeStripePayment.tsx +110 -5
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/shell/TribeNestApp.tsx +6 -0
- package/src/ui/styled/AccountDashboard.tsx +239 -15
- package/src/ui/styled/DocumentSigningPage.tsx +516 -0
- package/src/ui/styled/EventConfirmation.tsx +106 -9
- package/src/ui/styled/EventTickets.tsx +107 -13
- 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/ReviewRequestPage.tsx +214 -0
- 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
- package/src/ui/styled/forge-utilities.css +310 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
- 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.
|
|
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?.
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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}
|