@tribe-nest/forge 3.53.0 → 3.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/package.json +1 -1
  2. package/src/contexts/AppAuthContext.tsx +3 -2
  3. package/src/contexts/AudioPlayerContext.tsx +3 -2
  4. package/src/contexts/CartContext.tsx +3 -2
  5. package/src/contexts/PublicAuthContext.tsx +3 -2
  6. package/src/data/queries/useCoachingProducts.ts +15 -1
  7. package/src/data/queries/useCourses.ts +15 -1
  8. package/src/data/queries/useEvents.ts +26 -1
  9. package/src/data/queries/useMembership.ts +176 -4
  10. package/src/data/queries/useMyTickets.ts +49 -0
  11. package/src/data/queries/usePaymentFlow.ts +8 -0
  12. package/src/data/queries/useProducts.ts +15 -1
  13. package/src/data/queries/useSubscriptions.ts +21 -1
  14. package/src/i18n/de.json +116 -1
  15. package/src/i18n/en.json +116 -1
  16. package/src/i18n/index.ts +3 -2
  17. package/src/index.ts +22 -0
  18. package/src/provider/ForgeAppProvider.tsx +8 -1
  19. package/src/provider/ForgeProvider.tsx +3 -2
  20. package/src/provider/SiteConfigProvider.tsx +3 -2
  21. package/src/runtime/RemotePage.tsx +110 -0
  22. package/src/runtime/hostRuntime.ts +84 -0
  23. package/src/runtime/pages.spec.ts +41 -0
  24. package/src/runtime/pages.ts +183 -0
  25. package/src/runtime/pagesClient.ts +127 -0
  26. package/src/runtime/registry.spec.ts +45 -0
  27. package/src/runtime/registry.ts +102 -0
  28. package/src/server/index.ts +74 -0
  29. package/src/server/jobs.ts +4 -1
  30. package/src/server/platformEvents.generated.ts +152 -0
  31. package/src/types/models.ts +185 -0
  32. package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
  33. package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
  34. package/src/ui/headless/event/ticketApproval.ts +109 -0
  35. package/src/ui/headless/event/useEventCheckout.ts +74 -4
  36. package/src/ui/headless/index.ts +34 -0
  37. package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
  38. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
  39. package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
  40. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
  41. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
  42. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
  43. package/src/ui/headless/membership/membershipApplication.ts +109 -0
  44. package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
  45. package/src/ui/headless/membership/membershipTrial.ts +53 -0
  46. package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
  47. package/src/ui/index.ts +27 -0
  48. package/src/ui/payment/ForgePaymentProvider.tsx +29 -0
  49. package/src/ui/payment/ForgeStripePayment.tsx +110 -5
  50. package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
  51. package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
  52. package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
  53. package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
  54. package/src/ui/payment/stripeIntentKind.ts +32 -0
  55. package/src/ui/shell/TribeNestApp.tsx +6 -0
  56. package/src/ui/styled/AccountDashboard.tsx +239 -15
  57. package/src/ui/styled/DocumentSigningPage.tsx +516 -0
  58. package/src/ui/styled/EventConfirmation.tsx +106 -9
  59. package/src/ui/styled/EventTickets.tsx +107 -13
  60. package/src/ui/styled/LoginForm.tsx +5 -3
  61. package/src/ui/styled/MembershipCheckout.tsx +596 -256
  62. package/src/ui/styled/MembershipTierCallout.tsx +17 -3
  63. package/src/ui/styled/MembershipTiers.tsx +148 -6
  64. package/src/ui/styled/ProductGrid.tsx +14 -2
  65. package/src/ui/styled/ReviewRequestPage.tsx +214 -0
  66. package/src/ui/styled/SignupForm.tsx +4 -2
  67. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
  68. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
  69. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
  70. package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
  71. package/src/ui/styled/forge-utilities.css +310 -0
  72. package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
  73. package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
  74. package/src/utils/ticketOrderOutcome.ts +57 -1
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ canWithdrawMembershipApplication,
4
+ membershipApplicationOutcome,
5
+ tierCardAction,
6
+ tierUsesApplication,
7
+ } from "../membershipApplication";
8
+ import type { MembershipApplicationStatus, MembershipTier } from "../../../../types/models";
9
+
10
+ const app = (status: MembershipApplicationStatus) => ({ id: "app-1", status, paymentWindowExpiresAt: null });
11
+
12
+ describe("tierCardAction", () => {
13
+ it("offers the tier in its own words when the fan has no application", () => {
14
+ expect(tierCardAction({})).toBe("join");
15
+ expect(tierCardAction({ joinMode: "open" })).toBe("join");
16
+ expect(tierCardAction({ joinMode: "invite" })).toBe("accept_invite");
17
+ expect(tierCardAction({ joinMode: "application" })).toBe("apply");
18
+ expect(tierCardAction({ joinMode: "application", myApplication: null })).toBe("apply");
19
+ });
20
+
21
+ it("shows where the fan's application stands instead of the button", () => {
22
+ expect(tierCardAction({ joinMode: "application", myApplication: app("pending") })).toBe("pending");
23
+ expect(tierCardAction({ joinMode: "application", myApplication: app("approved") })).toBe("approved");
24
+ expect(tierCardAction({ joinMode: "application", myApplication: app("payment_failed") })).toBe("complete");
25
+ expect(tierCardAction({ joinMode: "application", myApplication: app("declined") })).toBe("declined");
26
+ });
27
+
28
+ it("lets a fan pick an unfinished card step back up", () => {
29
+ expect(tierCardAction({ joinMode: "application", myApplication: app("card_pending") })).toBe("apply");
30
+ });
31
+ });
32
+
33
+ describe("tierUsesApplication", () => {
34
+ it("is true on an application tier", () => {
35
+ expect(tierUsesApplication({ joinMode: "application" })).toBe(true);
36
+ expect(tierUsesApplication({ joinMode: "application", myApplication: app("pending") })).toBe(true);
37
+ });
38
+
39
+ it("EXCEPTION: a payment_failed application completes through the ordinary paid checkout", () => {
40
+ expect(tierUsesApplication({ joinMode: "application", myApplication: app("payment_failed") })).toBe(false);
41
+ });
42
+
43
+ it("is false on open and invite tiers, and with no tier", () => {
44
+ expect(tierUsesApplication({ joinMode: "open" } as Pick<MembershipTier, "joinMode">)).toBe(false);
45
+ expect(tierUsesApplication({ joinMode: "invite" })).toBe(false);
46
+ expect(tierUsesApplication({})).toBe(false);
47
+ expect(tierUsesApplication(undefined)).toBe(false);
48
+ });
49
+ });
50
+
51
+ describe("canWithdrawMembershipApplication", () => {
52
+ it("allows withdrawing only before a decision", () => {
53
+ expect(canWithdrawMembershipApplication("card_pending")).toBe(true);
54
+ expect(canWithdrawMembershipApplication("pending")).toBe(true);
55
+ for (const status of ["approved", "payment_failed", "joined", "declined", "withdrawn", "expired"] as const) {
56
+ expect(canWithdrawMembershipApplication(status)).toBe(false);
57
+ }
58
+ expect(canWithdrawMembershipApplication(undefined)).toBe(false);
59
+ });
60
+ });
61
+
62
+ describe("membershipApplicationOutcome", () => {
63
+ const outcome = (status: MembershipApplicationStatus, closedReason: string | null = null, confirmed = false) =>
64
+ membershipApplicationOutcome({ status, closedReason }, confirmed);
65
+
66
+ it("is null before anything was submitted, and while the card step is still ahead", () => {
67
+ expect(membershipApplicationOutcome(null)).toBeNull();
68
+ expect(outcome("card_pending")).toBeNull();
69
+ });
70
+
71
+ it("reads a confirmed card the server has not caught up with as received", () => {
72
+ expect(outcome("card_pending", null, true)).toBe("received");
73
+ });
74
+
75
+ it("maps every other status", () => {
76
+ expect(outcome("pending")).toBe("received");
77
+ expect(outcome("approved")).toBe("approved");
78
+ expect(outcome("joined")).toBe("approved");
79
+ expect(outcome("payment_failed")).toBe("approved");
80
+ expect(outcome("declined")).toBe("declined");
81
+ expect(outcome("withdrawn", "tier_full")).toBe("tier_full");
82
+ expect(outcome("withdrawn")).toBe("closed");
83
+ expect(outcome("expired", "expired")).toBe("closed");
84
+ });
85
+ });
@@ -39,6 +39,8 @@ vi.mock("../../../../contexts/PublicAuthContext", async (importOriginal) => ({
39
39
  vi.mock("../../../../data/queries/useMembership", async (importOriginal) => ({
40
40
  ...(await importOriginal<typeof import("../../../../data/queries/useMembership")>()),
41
41
  useGetMembershipTiers: () => ({ data: tiers, isLoading: false }),
42
+ useApplyForMembership: () => ({ mutateAsync: vi.fn(), isPending: false }),
43
+ useConfirmMembershipApplication: () => ({ mutateAsync: vi.fn(), isPending: false }),
42
44
  }));
43
45
 
44
46
  vi.mock("../../../../data/queries/useSubscriptions", async (importOriginal) => ({
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatTrialDate, isMembershipInTrial, tierTrialDays, trialEndDate } from "../membershipTrial";
3
+
4
+ /**
5
+ * Free trials on paid membership tiers: who gets one, when it ends, and whether
6
+ * a membership is still inside it. Every surface reads these, so they are pinned
7
+ * as pure functions.
8
+ */
9
+ describe("tierTrialDays", () => {
10
+ it("gives the tier's trial to an eligible fan, and to a signed-out visitor (no trialEligible)", () => {
11
+ expect(tierTrialDays({ trialDays: 14, trialEligible: true })).toBe(14);
12
+ expect(tierTrialDays({ trialDays: 7 })).toBe(7);
13
+ });
14
+
15
+ it("gives none to a fan who already had a trial with this business", () => {
16
+ expect(tierTrialDays({ trialDays: 14, trialEligible: false })).toBe(0);
17
+ });
18
+
19
+ it("gives none when the tier has no trial", () => {
20
+ expect(tierTrialDays({ trialDays: null })).toBe(0);
21
+ expect(tierTrialDays({ trialDays: 0 })).toBe(0);
22
+ expect(tierTrialDays({ trialDays: -3 })).toBe(0);
23
+ expect(tierTrialDays({})).toBe(0);
24
+ expect(tierTrialDays(undefined)).toBe(0);
25
+ });
26
+
27
+ it("gives none to a member with a live paid subscription (that is a tier change)", () => {
28
+ expect(
29
+ tierTrialDays({ trialDays: 14 }, { paymentProviderSubscriptionId: "sub_1", status: "active" }),
30
+ ).toBe(0);
31
+ // A lapsed or free membership does not block the trial.
32
+ expect(tierTrialDays({ trialDays: 14 }, { paymentProviderSubscriptionId: "sub_1", status: "cancelled" })).toBe(14);
33
+ expect(tierTrialDays({ trialDays: 14 }, { status: "active" })).toBe(14);
34
+ });
35
+ });
36
+
37
+ describe("trialEndDate", () => {
38
+ it("is today plus the trial days", () => {
39
+ const now = new Date("2026-09-17T10:00:00.000Z");
40
+ expect(trialEndDate(14, now).toISOString()).toBe("2026-10-01T10:00:00.000Z");
41
+ });
42
+ });
43
+
44
+ describe("isMembershipInTrial", () => {
45
+ const now = new Date("2026-09-17T10:00:00.000Z");
46
+ it("is true only while trialEndsAt is in the future", () => {
47
+ expect(isMembershipInTrial({ trialEndsAt: "2026-09-30T00:00:00.000Z" }, now)).toBe(true);
48
+ expect(isMembershipInTrial({ trialEndsAt: "2026-09-01T00:00:00.000Z" }, now)).toBe(false);
49
+ expect(isMembershipInTrial({ trialEndsAt: null }, now)).toBe(false);
50
+ expect(isMembershipInTrial({}, now)).toBe(false);
51
+ expect(isMembershipInTrial(undefined, now)).toBe(false);
52
+ expect(isMembershipInTrial({ trialEndsAt: "not a date" }, now)).toBe(false);
53
+ });
54
+ });
55
+
56
+ describe("formatTrialDate", () => {
57
+ it("formats per locale", () => {
58
+ expect(formatTrialDate("2026-10-01T12:00:00.000Z", "en")).toBe("Oct 1, 2026");
59
+ expect(formatTrialDate("2026-10-01T12:00:00.000Z", "de")).toContain("2026");
60
+ });
61
+ });
@@ -0,0 +1,253 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ import { renderHook, act } from "@testing-library/react";
4
+ import { useMembershipCheckout } from "../useMembershipCheckout";
5
+ import type { MembershipApplication, MembershipTier } from "../../../../types/models";
6
+
7
+ /**
8
+ * Curated access in the headless checkout: an application tier APPLIES instead
9
+ * of subscribing (free lodges it, paid saves a card through a SetupIntent and
10
+ * confirms), a `payment_failed` application completes through the ordinary
11
+ * paid subscribe, and an invite token rides on the tier list and on both
12
+ * subscribe calls. Every seam is mocked at the module boundary.
13
+ */
14
+
15
+ const auth = vi.hoisted(() => ({
16
+ user: { id: "account-1", email: "fan@example.com", membership: undefined as unknown },
17
+ refetchUser: vi.fn(async () => {}),
18
+ }));
19
+
20
+ const api = vi.hoisted(() => ({
21
+ tiersOptions: [] as unknown[],
22
+ apply: vi.fn(async (_body: Record<string, unknown>) => ({}) as Record<string, unknown>),
23
+ confirm: vi.fn(async (_body: Record<string, unknown>) => ({}) as Record<string, unknown>),
24
+ createFree: vi.fn(async (_body: Record<string, unknown>) => ({})),
25
+ createPaid: vi.fn(async (_body: Record<string, unknown>) => ({}) as Record<string, unknown>),
26
+ }));
27
+
28
+ let tiers: MembershipTier[] = [];
29
+
30
+ vi.mock("../../../../contexts/PublicAuthContext", async (importOriginal) => ({
31
+ ...(await importOriginal<typeof import("../../../../contexts/PublicAuthContext")>()),
32
+ usePublicAuth: () => ({ user: auth.user, refetchUser: auth.refetchUser }),
33
+ }));
34
+
35
+ vi.mock("../../../../data/queries/useMembership", async (importOriginal) => ({
36
+ ...(await importOriginal<typeof import("../../../../data/queries/useMembership")>()),
37
+ useGetMembershipTiers: (opts: unknown) => {
38
+ api.tiersOptions.push(opts);
39
+ return { data: tiers, isLoading: false };
40
+ },
41
+ useApplyForMembership: () => ({ mutateAsync: api.apply, isPending: false }),
42
+ useConfirmMembershipApplication: () => ({ mutateAsync: api.confirm, isPending: false }),
43
+ }));
44
+
45
+ vi.mock("../../../../data/queries/useSubscriptions", async (importOriginal) => ({
46
+ ...(await importOriginal<typeof import("../../../../data/queries/useSubscriptions")>()),
47
+ useCreateSubscription: () => ({ mutateAsync: api.createPaid, isPending: false }),
48
+ useCreateFreeSubscription: () => ({ mutateAsync: api.createFree, isPending: false }),
49
+ useConfirmLatestSubscription: () => ({ mutateAsync: vi.fn(), isPending: false }),
50
+ }));
51
+
52
+ const tier = (overrides: Partial<MembershipTier>): MembershipTier => ({
53
+ id: "tier-1",
54
+ name: "Inner Circle",
55
+ description: "",
56
+ payWhatYouWant: false,
57
+ benefits: [],
58
+ ...overrides,
59
+ });
60
+
61
+ const application = (overrides: Partial<MembershipApplication> = {}): MembershipApplication => ({
62
+ id: "app-1",
63
+ profileId: "profile-1",
64
+ membershipTierId: "tier-1",
65
+ accountId: "account-1",
66
+ status: "pending",
67
+ billingCycle: null,
68
+ amount: null,
69
+ currency: null,
70
+ message: null,
71
+ instrumentLabel: null,
72
+ decidedAt: null,
73
+ decisionNote: null,
74
+ closedReason: null,
75
+ paymentWindowExpiresAt: null,
76
+ membershipId: null,
77
+ createdAt: "2026-09-17T00:00:00.000Z",
78
+ updatedAt: "2026-09-17T00:00:00.000Z",
79
+ ...overrides,
80
+ });
81
+
82
+ beforeEach(() => {
83
+ api.tiersOptions.length = 0;
84
+ api.apply.mockReset();
85
+ api.confirm.mockReset();
86
+ api.createFree.mockReset();
87
+ api.createFree.mockResolvedValue({});
88
+ api.createPaid.mockReset();
89
+ auth.refetchUser.mockClear();
90
+ window.history.replaceState(null, "", "/i/membership/checkout");
91
+ });
92
+
93
+ const onComplete = vi.fn();
94
+
95
+ describe("application tiers: subscribe() applies", () => {
96
+ it("free tier: lodges the application and shows it received, no subscription", async () => {
97
+ tiers = [tier({ id: "tier-free", priceMonthly: 0, joinMode: "application" })];
98
+ api.apply.mockResolvedValue({ application: application({ membershipTierId: "tier-free" }), requiresCard: false, clientSecret: null });
99
+ const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-free", onComplete }));
100
+
101
+ expect(result.current.isApplication).toBe(true);
102
+ // The free-text note is gone from the checkout: the tier's questions replaced it.
103
+ expect(result.current).not.toHaveProperty("setApplicationMessage");
104
+ await act(async () => {
105
+ await result.current.subscribe();
106
+ });
107
+
108
+ expect(api.apply).toHaveBeenCalledTimes(1);
109
+ const body = api.apply.mock.calls[0][0];
110
+ expect(body).toMatchObject({ membershipTierId: "tier-free" });
111
+ expect(body).not.toHaveProperty("message");
112
+ // A tier without questions sends none.
113
+ expect(body).not.toHaveProperty("questionnaire");
114
+ // A free tier sends no cycle and no amount.
115
+ expect(body).not.toHaveProperty("billingCycle");
116
+ expect(body).not.toHaveProperty("amount");
117
+ expect(api.createFree).not.toHaveBeenCalled();
118
+ expect(api.createPaid).not.toHaveBeenCalled();
119
+ expect(result.current.applicationOutcome).toBe("received");
120
+ expect(result.current.step).toBe("select");
121
+ expect(onComplete).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it("paid tier: saves the card through a SetupIntent, then confirms, then shows it received", async () => {
125
+ tiers = [tier({ priceMonthly: 12, joinMode: "application" })];
126
+ api.apply.mockResolvedValue({
127
+ application: application({ status: "card_pending", billingCycle: "month", amount: 12, currency: "USD" }),
128
+ requiresCard: true,
129
+ clientSecret: "seti_123_secret_abc",
130
+ });
131
+ api.confirm.mockResolvedValue({
132
+ application: application({ status: "pending", billingCycle: "month", amount: 12, currency: "USD" }),
133
+ });
134
+ const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", onComplete }));
135
+
136
+ await act(async () => {
137
+ await result.current.subscribe();
138
+ });
139
+
140
+ expect(api.apply.mock.calls[0][0]).toMatchObject({ membershipTierId: "tier-1", billingCycle: "month", amount: 12 });
141
+ expect(api.createPaid).not.toHaveBeenCalled();
142
+ expect(result.current.step).toBe("payment");
143
+ expect(result.current.clientSecret).toBe("seti_123_secret_abc");
144
+ expect(result.current.paymentKind).toBe("setup");
145
+ // The card step is still ahead: nothing to announce yet.
146
+ expect(result.current.applicationOutcome).toBeNull();
147
+ expect(result.current.applicationReturnUrl).toContain("confirmApplication=app-1");
148
+
149
+ await act(async () => {
150
+ await result.current.confirmApplication();
151
+ });
152
+
153
+ expect(api.confirm).toHaveBeenCalledWith({ applicationId: "app-1" });
154
+ expect(result.current.application?.status).toBe("pending");
155
+ expect(result.current.applicationOutcome).toBe("received");
156
+ expect(onComplete).not.toHaveBeenCalled();
157
+ });
158
+
159
+ it("surfaces a server refusal in the error slot", async () => {
160
+ tiers = [tier({ priceMonthly: 0, joinMode: "application" })];
161
+ api.apply.mockRejectedValue({ response: { data: { message: "This membership is full." } } });
162
+ const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1" }));
163
+
164
+ await act(async () => {
165
+ await result.current.subscribe();
166
+ });
167
+
168
+ expect(result.current.error).toBe("This membership is full.");
169
+ expect(result.current.applicationOutcome).toBeNull();
170
+ });
171
+
172
+ it("closes as tier_full when the last seat went while the card was saved", async () => {
173
+ tiers = [tier({ priceMonthly: 12, joinMode: "application" })];
174
+ api.apply.mockResolvedValue({
175
+ application: application({ status: "card_pending" }),
176
+ requiresCard: true,
177
+ clientSecret: "seti_1_secret_2",
178
+ });
179
+ api.confirm.mockResolvedValue({ application: application({ status: "withdrawn", closedReason: "tier_full" }) });
180
+ const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1" }));
181
+
182
+ await act(async () => {
183
+ await result.current.subscribe();
184
+ });
185
+ await act(async () => {
186
+ await result.current.confirmApplication();
187
+ });
188
+
189
+ expect(result.current.applicationOutcome).toBe("tier_full");
190
+ });
191
+
192
+ it("EXCEPTION: a payment_failed application runs the ordinary paid subscribe", async () => {
193
+ tiers = [
194
+ tier({
195
+ priceMonthly: 12,
196
+ joinMode: "application",
197
+ myApplication: { id: "app-1", status: "payment_failed", paymentWindowExpiresAt: "2026-09-24T00:00:00.000Z" },
198
+ }),
199
+ ];
200
+ api.createPaid.mockResolvedValue({ requiresPayment: true, clientSecret: "pi_1_secret_2" });
201
+ const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1" }));
202
+
203
+ expect(result.current.isApplication).toBe(false);
204
+ await act(async () => {
205
+ await result.current.subscribe();
206
+ });
207
+
208
+ expect(api.apply).not.toHaveBeenCalled();
209
+ expect(api.createPaid).toHaveBeenCalledTimes(1);
210
+ expect(result.current.paymentKind).toBe("payment");
211
+ expect(result.current.clientSecret).toBe("pi_1_secret_2");
212
+ });
213
+ });
214
+
215
+ describe("invite tokens", () => {
216
+ it("reads the invite off the url and forwards it to the tier list and the paid subscribe", async () => {
217
+ window.history.replaceState(null, "", "/i/membership/checkout?membershipTierId=tier-1&invite=tok-abc");
218
+ tiers = [tier({ priceMonthly: 12, joinMode: "invite" })];
219
+ api.createPaid.mockResolvedValue({ requiresPayment: true, clientSecret: "pi_1_secret_2" });
220
+ const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1" }));
221
+
222
+ expect(result.current.inviteToken).toBe("tok-abc");
223
+ expect(api.tiersOptions.at(-1)).toEqual({ inviteToken: "tok-abc" });
224
+ await act(async () => {
225
+ await result.current.subscribe();
226
+ });
227
+
228
+ expect(api.createPaid.mock.calls[0][0]).toMatchObject({ membershipTierId: "tier-1", inviteToken: "tok-abc" });
229
+ });
230
+
231
+ it("forwards an explicit token on a free join", async () => {
232
+ tiers = [tier({ priceMonthly: 0, joinMode: "invite" })];
233
+ const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", inviteToken: "tok-free", onComplete }));
234
+
235
+ await act(async () => {
236
+ await result.current.subscribe();
237
+ });
238
+
239
+ expect(api.createFree.mock.calls[0][0]).toMatchObject({ membershipTierId: "tier-1", inviteToken: "tok-free" });
240
+ });
241
+
242
+ it("sends no token when there is none", async () => {
243
+ tiers = [tier({ priceMonthly: 0 })];
244
+ const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", onComplete }));
245
+
246
+ await act(async () => {
247
+ await result.current.subscribe();
248
+ });
249
+
250
+ expect(api.tiersOptions.at(-1)).toEqual({ inviteToken: null });
251
+ expect(api.createFree.mock.calls[0][0]).not.toHaveProperty("inviteToken");
252
+ });
253
+ });
@@ -0,0 +1,241 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach } from "vitest";
3
+ import { renderHook, act } from "@testing-library/react";
4
+ import { useMembershipCheckout } from "../useMembershipCheckout";
5
+ import { buildQuestionnaireAnswers, firstMissingAnswer, tierQuestionnaire } from "../membershipQuestionnaire";
6
+ import type { MembershipTier, QuestionnaireQuestion } from "../../../../types/models";
7
+
8
+ /**
9
+ * Tier questionnaires in the headless checkout: every join mode (open free,
10
+ * open paid, application) asks the tier's questions, refuses a blank required
11
+ * answer before any request, and sends the answers in the body. Completing a
12
+ * `payment_failed` application asks nothing and sends nothing.
13
+ */
14
+
15
+ const auth = vi.hoisted(() => ({
16
+ user: { id: "account-1", email: "fan@example.com", membership: undefined as unknown },
17
+ refetchUser: vi.fn(async () => {}),
18
+ }));
19
+
20
+ const api = vi.hoisted(() => ({
21
+ apply: vi.fn(async (_body: Record<string, unknown>) => ({}) as Record<string, unknown>),
22
+ createFree: vi.fn(async (_body: Record<string, unknown>) => ({})),
23
+ createPaid: vi.fn(async (_body: Record<string, unknown>) => ({}) as Record<string, unknown>),
24
+ }));
25
+
26
+ let tiers: MembershipTier[] = [];
27
+
28
+ vi.mock("../../../../contexts/PublicAuthContext", async (importOriginal) => ({
29
+ ...(await importOriginal<typeof import("../../../../contexts/PublicAuthContext")>()),
30
+ usePublicAuth: () => ({ user: auth.user, refetchUser: auth.refetchUser }),
31
+ }));
32
+
33
+ vi.mock("../../../../data/queries/useMembership", async (importOriginal) => ({
34
+ ...(await importOriginal<typeof import("../../../../data/queries/useMembership")>()),
35
+ useGetMembershipTiers: () => ({ data: tiers, isLoading: false }),
36
+ useApplyForMembership: () => ({ mutateAsync: api.apply, isPending: false }),
37
+ useConfirmMembershipApplication: () => ({ mutateAsync: vi.fn(), isPending: false }),
38
+ }));
39
+
40
+ vi.mock("../../../../data/queries/useSubscriptions", async (importOriginal) => ({
41
+ ...(await importOriginal<typeof import("../../../../data/queries/useSubscriptions")>()),
42
+ useCreateSubscription: () => ({ mutateAsync: api.createPaid, isPending: false }),
43
+ useCreateFreeSubscription: () => ({ mutateAsync: api.createFree, isPending: false }),
44
+ useConfirmLatestSubscription: () => ({ mutateAsync: vi.fn(), isPending: false }),
45
+ }));
46
+
47
+ const questions: QuestionnaireQuestion[] = [
48
+ { id: "q-why", question: "Why do you want to join?", type: "textarea" },
49
+ { id: "q-city", question: "Which city?", type: "select", options: ["Berlin", "Lagos"] },
50
+ { id: "q-handle", question: "Instagram handle", type: "text", optional: true },
51
+ ];
52
+
53
+ /** Exactly what the server is sent when the fan answers the two required questions. */
54
+ const expectedAnswers = [
55
+ { id: "q-why", question: "Why do you want to join?", type: "textarea", answer: "Long-time listener" },
56
+ { id: "q-city", question: "Which city?", type: "select", options: ["Berlin", "Lagos"], answer: "Lagos" },
57
+ { id: "q-handle", question: "Instagram handle", type: "text", answer: "" },
58
+ ];
59
+
60
+ const tier = (overrides: Partial<MembershipTier>): MembershipTier => ({
61
+ id: "tier-1",
62
+ name: "Inner Circle",
63
+ description: "",
64
+ payWhatYouWant: false,
65
+ benefits: [],
66
+ questionnaire: questions,
67
+ ...overrides,
68
+ });
69
+
70
+ const onComplete = vi.fn();
71
+
72
+ beforeEach(() => {
73
+ api.apply.mockReset();
74
+ api.createFree.mockReset();
75
+ api.createFree.mockResolvedValue({});
76
+ api.createPaid.mockReset();
77
+ onComplete.mockClear();
78
+ window.history.replaceState(null, "", "/i/membership/checkout");
79
+ });
80
+
81
+ function answerRequired(result: { current: ReturnType<typeof useMembershipCheckout> }) {
82
+ act(() => result.current.setQuestionnaireAnswer("q-why", " Long-time listener "));
83
+ act(() => result.current.setQuestionnaireAnswer("q-city", "Lagos"));
84
+ }
85
+
86
+ describe("answers are sent on every join call", () => {
87
+ it("open free tier: /subscriptions/free carries the answers", async () => {
88
+ tiers = [tier({ priceMonthly: 0 })];
89
+ const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", onComplete }));
90
+
91
+ expect(result.current.questionnaire).toEqual(questions);
92
+ answerRequired(result);
93
+ expect(result.current.questionnaireAnswers).toEqual({ "q-why": " Long-time listener ", "q-city": "Lagos" });
94
+ await act(async () => {
95
+ await result.current.subscribe();
96
+ });
97
+
98
+ expect(api.createFree).toHaveBeenCalledTimes(1);
99
+ expect(api.createFree.mock.calls[0][0]).toMatchObject({ membershipTierId: "tier-1" });
100
+ expect(api.createFree.mock.calls[0][0].questionnaire).toEqual(expectedAnswers);
101
+ expect(result.current.questionnaireRefusal).toBeNull();
102
+ });
103
+
104
+ it("open paid tier: /subscriptions carries the answers", async () => {
105
+ tiers = [tier({ priceMonthly: 12 })];
106
+ api.createPaid.mockResolvedValue({ requiresPayment: true, clientSecret: "pi_1_secret_2" });
107
+ const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1" }));
108
+
109
+ answerRequired(result);
110
+ await act(async () => {
111
+ await result.current.subscribe();
112
+ });
113
+
114
+ expect(api.createPaid).toHaveBeenCalledTimes(1);
115
+ expect(api.createPaid.mock.calls[0][0]).toMatchObject({ membershipTierId: "tier-1", amount: 12, billingCycle: "month" });
116
+ expect(api.createPaid.mock.calls[0][0].questionnaire).toEqual(expectedAnswers);
117
+ expect(result.current.step).toBe("payment");
118
+ });
119
+
120
+ it("invite tier: /subscriptions carries the answers and the token", async () => {
121
+ tiers = [tier({ priceMonthly: 12, joinMode: "invite" })];
122
+ api.createPaid.mockResolvedValue({ requiresPayment: true, clientSecret: "pi_1_secret_2" });
123
+ const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", inviteToken: "tok-1" }));
124
+
125
+ answerRequired(result);
126
+ await act(async () => {
127
+ await result.current.subscribe();
128
+ });
129
+
130
+ expect(api.createPaid.mock.calls[0][0]).toMatchObject({ inviteToken: "tok-1", questionnaire: expectedAnswers });
131
+ });
132
+
133
+ it("application tier: /membership-applications carries the answers and no message", async () => {
134
+ tiers = [tier({ priceMonthly: 0, joinMode: "application" })];
135
+ api.apply.mockResolvedValue({
136
+ application: { id: "app-1", status: "pending", closedReason: null },
137
+ requiresCard: false,
138
+ clientSecret: null,
139
+ });
140
+ const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1" }));
141
+
142
+ answerRequired(result);
143
+ await act(async () => {
144
+ await result.current.subscribe();
145
+ });
146
+
147
+ expect(api.apply).toHaveBeenCalledTimes(1);
148
+ const body = api.apply.mock.calls[0][0];
149
+ expect(body.questionnaire).toEqual(expectedAnswers);
150
+ expect(body).not.toHaveProperty("message");
151
+ expect(result.current.applicationOutcome).toBe("received");
152
+ });
153
+ });
154
+
155
+ describe("a blank required answer is refused before any request", () => {
156
+ it.each([
157
+ ["open free", { priceMonthly: 0 }],
158
+ ["open paid", { priceMonthly: 12 }],
159
+ ["application", { priceMonthly: 12, joinMode: "application" as const }],
160
+ ])("%s: nothing is sent and the first blank question is named", async (_label, overrides) => {
161
+ tiers = [tier(overrides)];
162
+ const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", onComplete }));
163
+
164
+ // Whitespace is blank.
165
+ act(() => result.current.setQuestionnaireAnswer("q-why", " "));
166
+ expect(result.current.questionnaireRefusal).toBeNull();
167
+ await act(async () => {
168
+ await result.current.subscribe();
169
+ });
170
+
171
+ expect(api.createFree).not.toHaveBeenCalled();
172
+ expect(api.createPaid).not.toHaveBeenCalled();
173
+ expect(api.apply).not.toHaveBeenCalled();
174
+ expect(onComplete).not.toHaveBeenCalled();
175
+ expect(result.current.questionnaireRefusal?.id).toBe("q-why");
176
+
177
+ // The refusal follows the answers: it moves on, then clears.
178
+ act(() => result.current.setQuestionnaireAnswer("q-why", "Because"));
179
+ expect(result.current.questionnaireRefusal?.id).toBe("q-city");
180
+ act(() => result.current.setQuestionnaireAnswer("q-city", "Berlin"));
181
+ expect(result.current.questionnaireRefusal).toBeNull();
182
+ });
183
+ });
184
+
185
+ describe("completing an approved application whose card was declined", () => {
186
+ it("asks nothing and sends no questionnaire", async () => {
187
+ tiers = [
188
+ tier({
189
+ priceMonthly: 12,
190
+ joinMode: "application",
191
+ myApplication: { id: "app-1", status: "payment_failed", paymentWindowExpiresAt: "2026-09-24T00:00:00.000Z" },
192
+ }),
193
+ ];
194
+ api.createPaid.mockResolvedValue({ requiresPayment: true, clientSecret: "pi_1_secret_2" });
195
+ const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1" }));
196
+
197
+ expect(result.current.questionnaire).toEqual([]);
198
+ await act(async () => {
199
+ await result.current.subscribe();
200
+ });
201
+
202
+ expect(api.createPaid).toHaveBeenCalledTimes(1);
203
+ expect(api.createPaid.mock.calls[0][0]).not.toHaveProperty("questionnaire");
204
+ expect(result.current.questionnaireRefusal).toBeNull();
205
+ });
206
+ });
207
+
208
+ describe("changing tier", () => {
209
+ it("starts the next tier's answers blank", () => {
210
+ tiers = [
211
+ tier({ id: "tier-1", priceMonthly: 12 }),
212
+ tier({ id: "tier-2", priceMonthly: 20, questionnaire: [{ id: "q-why", question: "Why?", type: "text" }] }),
213
+ ];
214
+ const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1" }));
215
+
216
+ act(() => result.current.setQuestionnaireAnswer("q-why", "An answer for tier one"));
217
+ act(() => result.current.selectTier(tiers[1]));
218
+
219
+ expect(result.current.selectedTierId).toBe("tier-2");
220
+ expect(result.current.questionnaire).toEqual([{ id: "q-why", question: "Why?", type: "text" }]);
221
+ expect(result.current.questionnaireAnswers).toEqual({});
222
+ });
223
+ });
224
+
225
+ describe("pure helpers", () => {
226
+ it("a tier with no questions asks none", () => {
227
+ expect(tierQuestionnaire(undefined)).toEqual([]);
228
+ expect(tierQuestionnaire({ questionnaire: null })).toEqual([]);
229
+ expect(tierQuestionnaire({ questionnaire: [] })).toEqual([]);
230
+ });
231
+
232
+ it("optional questions are never missing", () => {
233
+ expect(firstMissingAnswer([questions[2]], {})).toBeNull();
234
+ });
235
+
236
+ it("builds every question in order with trimmed answers", () => {
237
+ expect(buildQuestionnaireAnswers(questions, { "q-why": " Long-time listener ", "q-city": "Lagos" })).toEqual(
238
+ expectedAnswers,
239
+ );
240
+ });
241
+ });