@tribe-nest/forge 3.58.0 → 3.60.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 (94) hide show
  1. package/package.json +1 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CommunityContext.tsx +192 -0
  4. package/src/data/queries/useChat.ts +42 -39
  5. package/src/data/queries/useCommunities.ts +343 -0
  6. package/src/data/queries/useCommunity.ts +93 -89
  7. package/src/data/queries/useMembership.ts +32 -7
  8. package/src/data/queries/useMembershipCalls.ts +116 -7
  9. package/src/data/queries/usePostCollections.ts +28 -16
  10. package/src/data/queries/usePosts.ts +16 -14
  11. package/src/data/queries/useSubscriptions.ts +22 -8
  12. package/src/i18n/de.json +145 -1
  13. package/src/i18n/en.json +145 -1
  14. package/src/index.ts +19 -7
  15. package/src/runtime/pages.spec.ts +31 -0
  16. package/src/runtime/pages.ts +21 -22
  17. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  18. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  19. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  20. package/src/ui/format/membershipGate.ts +19 -15
  21. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  22. package/src/ui/headless/_tests/communityHarness.tsx +205 -0
  23. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  24. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  25. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +382 -0
  26. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +151 -0
  27. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +286 -0
  28. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  29. package/src/ui/headless/auth/useSignupForm.ts +10 -2
  30. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  31. package/src/ui/headless/communities/index.ts +55 -0
  32. package/src/ui/headless/communities/paths.ts +36 -0
  33. package/src/ui/headless/communities/useCommunity.ts +140 -0
  34. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  35. package/src/ui/headless/communities/useCommunityHome.ts +88 -0
  36. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  37. package/src/ui/headless/communities/useCommunityJoin.ts +444 -0
  38. package/src/ui/headless/communities/useCommunityMembership.ts +136 -0
  39. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  40. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  41. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  42. package/src/ui/headless/index.ts +8 -4
  43. package/src/ui/headless/memberHome/index.ts +1 -1
  44. package/src/ui/headless/memberHome/useMemberHomeNav.ts +44 -42
  45. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  46. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  47. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  48. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  49. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  50. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  51. package/src/ui/headless/posts/upsell.ts +54 -8
  52. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  53. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  54. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  55. package/src/ui/headless/posts/usePostItem.ts +6 -5
  56. package/src/ui/index.ts +17 -12
  57. package/src/ui/styled/AccountDashboard.tsx +48 -256
  58. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  59. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  60. package/src/ui/styled/MembershipTiers.tsx +49 -11
  61. package/src/ui/styled/Paywall.tsx +1 -1
  62. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +1 -1
  63. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  64. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  65. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  66. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  67. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  68. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  69. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  70. package/src/ui/styled/communities/CommunityAbout.tsx +140 -0
  71. package/src/ui/styled/communities/CommunityHome.tsx +175 -0
  72. package/src/ui/styled/communities/CommunityJoinSheet.tsx +444 -0
  73. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +133 -0
  74. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  75. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  76. package/src/ui/styled/communities/MyMembershipsList.tsx +89 -0
  77. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  78. package/src/ui/styled/communities/index.ts +10 -0
  79. package/src/ui/styled/communities/shared.tsx +149 -0
  80. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  81. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  82. package/src/ui/styled/forge-utilities.css +19 -0
  83. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  84. package/src/ui/styled/members/MemberCallsCalendar.tsx +142 -10
  85. package/src/ui/styled/members/MemberHomeLayout.tsx +35 -17
  86. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  87. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  88. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  89. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  90. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  91. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  92. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  93. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  94. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
@@ -0,0 +1,382 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import { communityJoinStep, tierHasChoice, useCommunityJoin, type CommunityJoinStepInput } from "../communities/useCommunityJoin";
5
+ import type { CommunityInviteState } from "../../../data/queries/useCommunities";
6
+ import {
7
+ account,
8
+ calls,
9
+ detailRoute,
10
+ get,
11
+ installAdapter,
12
+ meRoute,
13
+ post,
14
+ restoreAdapter,
15
+ tier,
16
+ viewer,
17
+ wrapper,
18
+ type Route,
19
+ } from "./communityHarness";
20
+
21
+ const paystack = vi.hoisted(() => ({ open: vi.fn(async () => "opened" as const) }));
22
+ vi.mock("../../../utils/paystackCheckout", async (importOriginal) => ({
23
+ ...(await importOriginal<typeof import("../../../utils/paystackCheckout")>()),
24
+ openPaystackCheckout: paystack.open,
25
+ }));
26
+
27
+ /**
28
+ * The join sheet's step machine, driven with no markup.
29
+ *
30
+ * email -> code -> tier -> questions -> payment -> inside
31
+ *
32
+ * Every step that does not apply is skipped, every success lands INSIDE the
33
+ * community (never `/i/account`), invites preset their tier and lock their
34
+ * email, and a dead invite falls back to the normal join without its token.
35
+ */
36
+
37
+ const HOME = "/i/communities/fan-club/home";
38
+ let onJoined: ReturnType<typeof vi.fn>;
39
+
40
+ beforeEach(() => {
41
+ onJoined = vi.fn();
42
+ paystack.open.mockClear();
43
+ window.history.replaceState(null, "", "/i/communities/fan-club");
44
+ });
45
+ afterEach(restoreAdapter);
46
+
47
+ const free = tier({ id: "tier-free", name: "Free" });
48
+ const paid = tier({ id: "tier-paid", name: "Paid", priceMonthly: 10 });
49
+ const both = tier({ id: "tier-both", name: "Both", priceMonthly: 10, priceYearly: 100 });
50
+ const apply = tier({ id: "tier-apply", name: "Apply", joinMode: "application" });
51
+ const invited = tier({ id: "tier-vip", name: "VIP", joinMode: "invite" });
52
+
53
+ function routes({
54
+ tiers,
55
+ me = () => viewer(),
56
+ invite,
57
+ extra = [],
58
+ }: {
59
+ tiers: ReturnType<typeof tier>[];
60
+ me?: () => ReturnType<typeof viewer>;
61
+ invite?: CommunityInviteState;
62
+ extra?: Route[];
63
+ }): Route[] {
64
+ return [
65
+ ...extra,
66
+ get(/^\/public\/communities\/fan-club\/invite$/, () => invite),
67
+ meRoute(me),
68
+ detailRoute((req) => ({
69
+ // The invite-only tier is listed only with a valid token, as on the server.
70
+ tiers: req.params?.invite || req.params?.join ? [...tiers, invited] : tiers,
71
+ })),
72
+ post("/public/payments/subscriptions/free", () => ({ membershipId: "m-1", communitySlug: "fan-club" })),
73
+ post("/public/payments/subscriptions", () => ({
74
+ membershipId: "m-1",
75
+ subscriptionId: "s-1",
76
+ clientSecret: "pi_1_secret_2",
77
+ requiresPayment: true,
78
+ })),
79
+ get("/public/websites/site-config", () => ({ currency: "USD" })),
80
+ ];
81
+ }
82
+
83
+ async function signUp(result: { current: ReturnType<typeof useCommunityJoin> }, email = "fan@example.com") {
84
+ act(() => {
85
+ result.current.signup.setFirstName("Fan");
86
+ result.current.signup.setLastName("Person");
87
+ if (!result.current.emailLocked) result.current.signup.setEmail(email);
88
+ result.current.signup.setPassword("password123");
89
+ result.current.signup.setAcceptedTerms(true);
90
+ });
91
+ await act(async () => {
92
+ await result.current.next();
93
+ });
94
+ expect(result.current.step).toBe("code");
95
+ act(() => result.current.setCode("123456"));
96
+ await act(async () => {
97
+ await result.current.next();
98
+ });
99
+ }
100
+
101
+ describe("the step order", () => {
102
+ const base: CommunityJoinStepInput = {
103
+ isAuthenticated: true,
104
+ awaitingCode: false,
105
+ emailMismatch: false,
106
+ joined: false,
107
+ pending: false,
108
+ alreadyMember: false,
109
+ isProcessing: false,
110
+ hasPaymentStep: false,
111
+ hasSelectedTier: true,
112
+ tierStepOpen: false,
113
+ questionsOpen: false,
114
+ };
115
+ it.each<[string, Partial<CommunityJoinStepInput>, string]>([
116
+ ["anonymous asks who the fan is", { isAuthenticated: false }, "email"],
117
+ ["then the emailed code", { isAuthenticated: false, awaitingCode: true }, "code"],
118
+ ["a mismatched invite stays on identity even when signed in", { emailMismatch: true }, "email"],
119
+ ["no tier chosen yet", { hasSelectedTier: false }, "tier"],
120
+ ["a tier with a choice to make", { tierStepOpen: true }, "tier"],
121
+ ["questions after the tier", { questionsOpen: true }, "questions"],
122
+ ["the card once a paid join started", { hasPaymentStep: true, questionsOpen: true }, "payment"],
123
+ ["in flight", { isProcessing: true }, "submitting"],
124
+ ["nothing left to ask submits", {}, "submitting"],
125
+ ["an application lodged", { pending: true }, "pending"],
126
+ ["joined", { joined: true }, "joined"],
127
+ ["already a member goes inside", { alreadyMember: true }, "joined"],
128
+ ])("%s", (_label, patch, step) => {
129
+ expect(communityJoinStep({ ...base, ...patch })).toBe(step);
130
+ });
131
+
132
+ it("a tier asks for a choice only with two cycles or a pay-what-you-want amount", () => {
133
+ expect(tierHasChoice(free)).toBe(false);
134
+ expect(tierHasChoice(paid)).toBe(false);
135
+ expect(tierHasChoice(both)).toBe(true);
136
+ expect(tierHasChoice(tier({ payWhatYouWant: true, payWhatYouWantMinimum: 5 }))).toBe(true);
137
+ });
138
+ });
139
+
140
+ describe("signed out, one free tier: the code is the last step", () => {
141
+ it("email -> code -> joined free, landing inside with welcome", async () => {
142
+ installAdapter(routes({ tiers: [free] }));
143
+ const { result } = renderHook(() => useCommunityJoin({ onJoined }), { wrapper: wrapper() });
144
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
145
+ expect(result.current.step).toBe("email");
146
+
147
+ await signUp(result);
148
+
149
+ await waitFor(() => expect(calls("post", "/public/payments/subscriptions/free")).toHaveLength(1));
150
+ expect(calls("post", "/public/payments/subscriptions/free")[0].body).toMatchObject({
151
+ membershipTierId: "tier-free",
152
+ profileId: "profile-1",
153
+ });
154
+ await waitFor(() => expect(result.current.step).toBe("joined"));
155
+ expect(onJoined).toHaveBeenCalledWith({ pending: false, requiresConfirmation: false, path: `${HOME}?welcome=1` });
156
+ expect(JSON.stringify(onJoined.mock.calls)).not.toContain("/i/account");
157
+ });
158
+ });
159
+
160
+ describe("signed in", () => {
161
+ it("several tiers: asks which, then joins the chosen one", async () => {
162
+ installAdapter(routes({ tiers: [free, paid] }));
163
+ const { result } = renderHook(() => useCommunityJoin({ onJoined }), { wrapper: wrapper({ user: account() }) });
164
+ await waitFor(() => expect(result.current.step).toBe("tier"));
165
+ expect(result.current.hasTierChoice).toBe(true);
166
+ act(() => result.current.selectTier(free));
167
+ await act(async () => {
168
+ await result.current.next();
169
+ });
170
+ await waitFor(() => expect(result.current.step).toBe("joined"));
171
+ expect(calls("post", "/public/payments/subscriptions/free")[0].body).toMatchObject({ membershipTierId: "tier-free" });
172
+ });
173
+
174
+ it("?tier= preselects a tier; a tier with a cycle choice still asks", async () => {
175
+ installAdapter(routes({ tiers: [free, both] }));
176
+ const { result } = renderHook(() => useCommunityJoin({ tierId: "tier-both", onJoined }), { wrapper: wrapper({ user: account() }) });
177
+ await waitFor(() => expect(result.current.selectedTier?.id).toBe("tier-both"));
178
+ expect(result.current.step).toBe("tier");
179
+ act(() => result.current.setBillingCycle("year"));
180
+ await act(async () => {
181
+ await result.current.next();
182
+ });
183
+ await waitFor(() => expect(result.current.step).toBe("payment"));
184
+ expect(calls("post", "/public/payments/subscriptions")[0].body).toMatchObject({
185
+ membershipTierId: "tier-both",
186
+ billingCycle: "year",
187
+ amount: 100,
188
+ });
189
+ });
190
+
191
+ it("questions: a blank required answer is refused before anything is sent, then the answers go with the join", async () => {
192
+ const withQuestions = tier({
193
+ id: "tier-q",
194
+ questionnaire: [{ id: "q-why", question: "Why?", type: "text", optional: false }] as never,
195
+ });
196
+ installAdapter(routes({ tiers: [withQuestions] }));
197
+ const { result } = renderHook(() => useCommunityJoin({ onJoined }), { wrapper: wrapper({ user: account() }) });
198
+ await waitFor(() => expect(result.current.step).toBe("questions"));
199
+
200
+ await act(async () => {
201
+ await result.current.next();
202
+ });
203
+ expect(result.current.questionnaireRefusal?.id).toBe("q-why");
204
+ expect(result.current.step).toBe("questions");
205
+ expect(calls("post", "/public/payments/subscriptions/free")).toHaveLength(0);
206
+
207
+ act(() => result.current.setQuestionnaireAnswer("q-why", "Big fan"));
208
+ await act(async () => {
209
+ await result.current.next();
210
+ });
211
+ await waitFor(() => expect(result.current.step).toBe("joined"));
212
+ expect(calls("post", "/public/payments/subscriptions/free")[0].body?.questionnaire).toEqual([
213
+ expect.objectContaining({ id: "q-why", answer: "Big fan" }),
214
+ ]);
215
+ });
216
+
217
+ it("paid on Stripe: the card step, returning to the community home to reconcile", async () => {
218
+ installAdapter(routes({ tiers: [paid] }));
219
+ const { result } = renderHook(() => useCommunityJoin({ onJoined }), { wrapper: wrapper({ user: account() }) });
220
+ await waitFor(() => expect(result.current.step).toBe("payment"));
221
+ expect(result.current.clientSecret).toBe("pi_1_secret_2");
222
+ expect(result.current.paymentKind).toBe("payment");
223
+ expect(result.current.returnUrl).toBe(`${window.location.origin}${HOME}?confirmSubscription=1`);
224
+ expect(calls("post", "/public/payments/subscriptions")[0].body?.returnUrl).toBe(
225
+ `${window.location.origin}${HOME}?confirmSubscription=1`,
226
+ );
227
+
228
+ await act(async () => {
229
+ await result.current.onPaymentSucceeded();
230
+ });
231
+ expect(onJoined).toHaveBeenCalledWith({ pending: false, requiresConfirmation: true, path: `${HOME}?confirmSubscription=1` });
232
+ expect(result.current.step).toBe("joined");
233
+ });
234
+
235
+ it("paid on Paystack: the card step opens the provider's checkout", async () => {
236
+ installAdapter(
237
+ routes({
238
+ tiers: [paid],
239
+ extra: [
240
+ post("/public/payments/subscriptions", () => ({
241
+ membershipId: "m-1",
242
+ subscriptionId: "s-1",
243
+ accessCode: "ac_1",
244
+ checkoutUrl: "https://paystack.test/ac_1",
245
+ requiresPayment: true,
246
+ })),
247
+ ],
248
+ }),
249
+ );
250
+ const { result } = renderHook(() => useCommunityJoin({ onJoined }), { wrapper: wrapper({ user: account() }) });
251
+ await waitFor(() => expect(result.current.step).toBe("payment"));
252
+ expect(result.current.isPaystack).toBe(true);
253
+ expect(result.current.clientSecret).toBeUndefined();
254
+ expect(paystack.open).toHaveBeenCalledWith(
255
+ expect.objectContaining({ accessCode: "ac_1", returnUrl: `${window.location.origin}${HOME}?confirmSubscription=1` }),
256
+ expect.anything(),
257
+ );
258
+ expect(onJoined).not.toHaveBeenCalled();
259
+ });
260
+
261
+ it("apply-to-join lands inside, pending", async () => {
262
+ installAdapter(
263
+ routes({
264
+ tiers: [apply],
265
+ extra: [
266
+ post("/public/membership-applications", () => ({
267
+ application: { id: "app-1", membershipTierId: "tier-apply", status: "pending", createdAt: "2026-09-27T00:00:00.000Z" },
268
+ requiresCard: false,
269
+ clientSecret: null,
270
+ })),
271
+ ],
272
+ }),
273
+ );
274
+ const { result } = renderHook(() => useCommunityJoin({ onJoined }), { wrapper: wrapper({ user: account() }) });
275
+ await waitFor(() => expect(result.current.step).toBe("pending"));
276
+ expect(calls("post", "/public/membership-applications")[0].body).toMatchObject({ membershipTierId: "tier-apply" });
277
+ expect(onJoined).toHaveBeenCalledWith({ pending: true, requiresConfirmation: false, path: HOME });
278
+ });
279
+
280
+ it("a member of THIS community is taken straight inside, and nothing is sent", async () => {
281
+ installAdapter(
282
+ routes({
283
+ tiers: [free],
284
+ me: () =>
285
+ viewer({
286
+ state: "member",
287
+ membership: {
288
+ id: "m-1",
289
+ tierId: "tier-free",
290
+ tierName: "Free",
291
+ status: "active",
292
+ cancelAtPeriodEnd: false,
293
+ currentPeriodEnd: null,
294
+ pendingDowngradeTierId: null,
295
+ },
296
+ }),
297
+ }),
298
+ );
299
+ const { result } = renderHook(() => useCommunityJoin({ onJoined }), { wrapper: wrapper({ user: account() }) });
300
+ await waitFor(() => expect(result.current.step).toBe("joined"));
301
+ expect(result.current.alreadyMember).toBe(true);
302
+ await waitFor(() => expect(onJoined).toHaveBeenCalledWith({ pending: false, requiresConfirmation: false, path: HOME }));
303
+ expect(calls("post", /\/public\/payments\/subscriptions/)).toHaveLength(0);
304
+ });
305
+ });
306
+
307
+ describe("invites", () => {
308
+ const emailInvite = (state: CommunityInviteState["state"], invitedEmail = "invited@example.com"): CommunityInviteState => ({
309
+ kind: "email",
310
+ state,
311
+ invitedEmail,
312
+ tier: state === "valid" || state === "email_mismatch" ? invited : null,
313
+ });
314
+
315
+ it("a per-email invite presets its (invite-only) tier, prefills and locks the email, and forwards the token", async () => {
316
+ installAdapter(routes({ tiers: [free], invite: emailInvite("valid") }));
317
+ const { result } = renderHook(() => useCommunityJoin({ invite: "tok-1", onJoined }), { wrapper: wrapper() });
318
+ await waitFor(() => expect(result.current.emailLocked).toBe(true));
319
+ expect(result.current.signup.email).toBe("invited@example.com");
320
+ expect(result.current.lockedEmail).toBe("invited@example.com");
321
+
322
+ await signUp(result);
323
+
324
+ await waitFor(() => expect(result.current.step).toBe("joined"));
325
+ const [join] = calls("post", "/public/payments/subscriptions/free");
326
+ expect(join.body).toMatchObject({ membershipTierId: "tier-vip", inviteToken: "tok-1" });
327
+ expect(join.body).not.toHaveProperty("joinToken");
328
+ });
329
+
330
+ it("signed in as someone else: the mismatch is shown with Sign in as, which signs out into a locked sign-in", async () => {
331
+ const logout = vi.fn();
332
+ installAdapter(routes({ tiers: [free], invite: emailInvite("valid") }));
333
+ const { result } = renderHook(() => useCommunityJoin({ invite: "tok-1", onJoined }), {
334
+ wrapper: wrapper({ user: account("other@example.com"), controls: { logout } }),
335
+ });
336
+ await waitFor(() => expect(result.current.emailMismatch).not.toBeNull());
337
+ expect(result.current.step).toBe("email");
338
+ expect(result.current.emailMismatch).toEqual({ invitedEmail: "invited@example.com", signedInEmail: "other@example.com" });
339
+ expect(calls("post", /\/public\/payments\/subscriptions/)).toHaveLength(0);
340
+
341
+ await act(async () => {
342
+ await result.current.signInAsInvited();
343
+ });
344
+ expect(logout).toHaveBeenCalledTimes(1);
345
+ expect(result.current.mode).toBe("signin");
346
+ expect(result.current.emailMismatch).toBeNull();
347
+ await waitFor(() => expect(result.current.login.email).toBe("invited@example.com"));
348
+ expect(result.current.emailLocked).toBe(true);
349
+ });
350
+
351
+ it.each(["expired", "revoked", "used", "not_found"] as const)(
352
+ "a %s invite says why and falls back to the normal join, without the token",
353
+ async (state) => {
354
+ installAdapter(routes({ tiers: [free], invite: emailInvite(state) }));
355
+ const { result } = renderHook(() => useCommunityJoin({ invite: "tok-dead", onJoined }), { wrapper: wrapper({ user: account() }) });
356
+ await waitFor(() => expect(result.current.inviteProblem).toBe(state));
357
+ expect(result.current.emailLocked).toBe(false);
358
+ await waitFor(() => expect(result.current.step).toBe("joined"));
359
+ const [join] = calls("post", "/public/payments/subscriptions/free");
360
+ expect(join.body).toMatchObject({ membershipTierId: "tier-free" });
361
+ expect(join.body).not.toHaveProperty("inviteToken");
362
+ },
363
+ );
364
+
365
+ it("a shareable link presets its tier with no email lock and forwards the join token", async () => {
366
+ installAdapter(routes({ tiers: [free], invite: { kind: "link", state: "valid", invitedEmail: null, tier: invited } }));
367
+ const { result } = renderHook(() => useCommunityJoin({ join: "link-1", onJoined }), { wrapper: wrapper({ user: account() }) });
368
+ await waitFor(() => expect(result.current.step).toBe("joined"));
369
+ expect(result.current.emailLocked).toBe(false);
370
+ const [join] = calls("post", "/public/payments/subscriptions/free");
371
+ expect(join.body).toMatchObject({ membershipTierId: "tier-vip", joinToken: "link-1" });
372
+ expect(join.body).not.toHaveProperty("inviteToken");
373
+ });
374
+
375
+ it("a used-up shareable link falls back without its token", async () => {
376
+ installAdapter(routes({ tiers: [free], invite: { kind: "link", state: "used_up", invitedEmail: null, tier: null } }));
377
+ const { result } = renderHook(() => useCommunityJoin({ join: "link-1", onJoined }), { wrapper: wrapper({ user: account() }) });
378
+ await waitFor(() => expect(result.current.inviteProblem).toBe("used_up"));
379
+ await waitFor(() => expect(result.current.step).toBe("joined"));
380
+ expect(calls("post", "/public/payments/subscriptions/free")[0].body).not.toHaveProperty("joinToken");
381
+ });
382
+ });
@@ -0,0 +1,151 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import { communityDowngradeTiers, useCommunityMembership } from "../communities/useCommunityMembership";
5
+ import { account, calls, detailRoute, installAdapter, meRoute, post, restoreAdapter, tier, viewer, wrapper } from "./communityHarness";
6
+
7
+ /**
8
+ * The Membership menu: cancel keeps the paid period and is two-step, a
9
+ * downgrade waits for period end and can be withdrawn, and every action is
10
+ * addressed to THIS community.
11
+ */
12
+
13
+ afterEach(restoreAdapter);
14
+
15
+ const free = tier({ id: "tier-free", name: "Free" });
16
+ const basic = tier({ id: "tier-basic", name: "Basic", priceMonthly: 5 });
17
+ const gold = tier({ id: "tier-gold", name: "Gold", priceMonthly: 20 });
18
+ const yearlyCheap = tier({ id: "tier-yearly", name: "Yearly", priceYearly: 60 });
19
+ const apply = tier({ id: "tier-apply", name: "Apply", priceMonthly: 1, joinMode: "application" });
20
+
21
+ let state = viewer();
22
+ const onGold = (patch: Record<string, unknown> = {}) =>
23
+ viewer({
24
+ state: "member",
25
+ membership: {
26
+ id: "m-1",
27
+ tierId: "tier-gold",
28
+ tierName: "Gold",
29
+ status: "active",
30
+ cancelAtPeriodEnd: false,
31
+ currentPeriodEnd: "2026-10-27T00:00:00.000Z",
32
+ pendingDowngradeTierId: null,
33
+ ...patch,
34
+ },
35
+ });
36
+
37
+ function install() {
38
+ installAdapter([
39
+ post("/public/communities/fan-club/membership/cancel", () => {
40
+ state = onGold({ cancelAtPeriodEnd: true });
41
+ return {};
42
+ }),
43
+ post("/public/communities/fan-club/membership/downgrade", (req) => {
44
+ state = onGold({ pendingDowngradeTierId: req.body?.membershipTierId });
45
+ return {};
46
+ }),
47
+ post("/public/communities/fan-club/membership/undo-downgrade", () => {
48
+ state = onGold();
49
+ return {};
50
+ }),
51
+ meRoute(() => state),
52
+ detailRoute(() => ({ tiers: [free, basic, gold, yearlyCheap, apply] })),
53
+ ]);
54
+ }
55
+
56
+ describe("communityDowngradeTiers", () => {
57
+ it("lists cheaper tiers of this community, on a monthly footing, never an application tier or an upgrade", () => {
58
+ const tiers = [free, basic, gold, yearlyCheap, apply];
59
+ expect(communityDowngradeTiers(tiers, "tier-gold", []).map((t) => t.id)).toEqual(["tier-free", "tier-basic", "tier-yearly"]);
60
+ expect(communityDowngradeTiers(tiers, "tier-gold", ["tier-basic"]).map((t) => t.id)).toEqual(["tier-free", "tier-yearly"]);
61
+ expect(communityDowngradeTiers(tiers, "tier-free", [])).toEqual([]);
62
+ expect(communityDowngradeTiers(tiers, null, [])).toEqual([]);
63
+ });
64
+ });
65
+
66
+ describe("useCommunityMembership", () => {
67
+ it("shows the tier, the renewal date and what it can move down to", async () => {
68
+ state = onGold();
69
+ install();
70
+ const { result } = renderHook(() => useCommunityMembership(), { wrapper: wrapper({ user: account() }) });
71
+ await waitFor(() => expect(result.current.hasMembership).toBe(true));
72
+ expect(result.current.membership?.tierName).toBe("Gold");
73
+ expect(result.current.periodEnd).toBe("2026-10-27T00:00:00.000Z");
74
+ await waitFor(() => expect(result.current.downgradeTiers.map((t) => t.id)).toEqual(["tier-free", "tier-basic", "tier-yearly"]));
75
+ });
76
+
77
+ it("cancel asks first, then cancels in this community and keeps the period", async () => {
78
+ state = onGold();
79
+ install();
80
+ const { result } = renderHook(() => useCommunityMembership(), { wrapper: wrapper({ user: account() }) });
81
+ await waitFor(() => expect(result.current.hasMembership).toBe(true));
82
+ act(() => result.current.requestCancel());
83
+ expect(result.current.cancelRequested).toBe(true);
84
+ expect(calls("post", /cancel$/)).toHaveLength(0);
85
+
86
+ await act(async () => {
87
+ await result.current.confirmCancel();
88
+ });
89
+ expect(calls("post", "/public/communities/fan-club/membership/cancel")[0].body).toEqual({ profileId: "profile-1" });
90
+ await waitFor(() => expect(result.current.isCancelling).toBe(true));
91
+ expect(result.current.cancelRequested).toBe(false);
92
+ });
93
+
94
+ it("abort leaves the membership alone", async () => {
95
+ state = onGold();
96
+ install();
97
+ const { result } = renderHook(() => useCommunityMembership(), { wrapper: wrapper({ user: account() }) });
98
+ await waitFor(() => expect(result.current.hasMembership).toBe(true));
99
+ act(() => result.current.requestCancel());
100
+ act(() => result.current.abortCancel());
101
+ expect(result.current.cancelRequested).toBe(false);
102
+ expect(calls("post", /cancel$/)).toHaveLength(0);
103
+ });
104
+
105
+ it("downgrade waits for period end and shows as pending; undo withdraws it", async () => {
106
+ state = onGold();
107
+ install();
108
+ const { result } = renderHook(() => useCommunityMembership(), { wrapper: wrapper({ user: account() }) });
109
+ await waitFor(() => expect(result.current.hasMembership).toBe(true));
110
+
111
+ await act(async () => {
112
+ await result.current.downgrade("tier-basic");
113
+ });
114
+ expect(calls("post", "/public/communities/fan-club/membership/downgrade")[0].body).toEqual({
115
+ profileId: "profile-1",
116
+ membershipTierId: "tier-basic",
117
+ });
118
+ await waitFor(() => expect(result.current.pendingDowngradeTier?.name).toBe("Basic"));
119
+
120
+ await act(async () => {
121
+ await result.current.undoDowngrade();
122
+ });
123
+ expect(calls("post", "/public/communities/fan-club/membership/undo-downgrade")).toHaveLength(1);
124
+ await waitFor(() => expect(result.current.pendingDowngradeTier).toBeNull());
125
+ });
126
+
127
+ it("a refused action is reported, not swallowed", async () => {
128
+ state = onGold();
129
+ installAdapter([
130
+ post("/public/communities/fan-club/membership/cancel", () => {
131
+ throw { status: 400, message: "Already cancelled." };
132
+ }),
133
+ meRoute(() => state),
134
+ detailRoute(() => ({ tiers: [gold] })),
135
+ ]);
136
+ const { result } = renderHook(() => useCommunityMembership(), { wrapper: wrapper({ user: account() }) });
137
+ await waitFor(() => expect(result.current.hasMembership).toBe(true));
138
+ await act(async () => {
139
+ await result.current.cancel();
140
+ });
141
+ expect(result.current.error).toBe("Already cancelled.");
142
+ });
143
+
144
+ it("has nothing to manage for a non-member", async () => {
145
+ state = viewer();
146
+ install();
147
+ const { result } = renderHook(() => useCommunityMembership(), { wrapper: wrapper({ user: account() }) });
148
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
149
+ expect(result.current.hasMembership).toBe(false);
150
+ });
151
+ });