@tribe-nest/forge 3.59.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,286 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import { communityJoinOffer, useCommunities, useCommunity } from "../communities/useCommunity";
5
+ import { useCommunityAbout } from "../communities/useCommunityAbout";
6
+ import { useCommunityHome } from "../communities/useCommunityHome";
7
+ import { toMyMembershipEntry, useMyMemberships } from "../communities/useMyMemberships";
8
+ import { useMemberHomeNav } from "../memberHome/useMemberHomeNav";
9
+ import {
10
+ COMMUNITY,
11
+ OTHER_COMMUNITY,
12
+ account,
13
+ calls,
14
+ detailRoute,
15
+ get,
16
+ installAdapter,
17
+ meRoute,
18
+ post,
19
+ restoreAdapter,
20
+ tier,
21
+ viewer,
22
+ wrapper,
23
+ } from "./communityHarness";
24
+
25
+ /**
26
+ * The read side of a community: the list, one community and where the viewer
27
+ * stands in it, the About page's one action, the home shell (reconciling a
28
+ * Stripe return, the upgrade opener), the nav under the community home, and
29
+ * the account page's memberships across communities.
30
+ */
31
+
32
+ afterEach(restoreAdapter);
33
+
34
+ const free = tier({ id: "tier-free", name: "Free" });
35
+ const paid = tier({ id: "tier-paid", priceMonthly: 12 });
36
+ const yearly = tier({ id: "tier-yearly", priceYearly: 60 });
37
+ const apply = tier({ id: "tier-apply", joinMode: "application" });
38
+
39
+ const member = viewer({
40
+ state: "member",
41
+ membership: { id: "m-1", tierId: "tier-paid", tierName: "Paid", status: "active", cancelAtPeriodEnd: false, currentPeriodEnd: null, pendingDowngradeTierId: null },
42
+ upgradeTiers: [{ id: "tier-gold", name: "Gold", priceMonthly: 30, priceYearly: null }],
43
+ canInteract: true,
44
+ });
45
+
46
+ describe("communityJoinOffer (the About page's Join button)", () => {
47
+ it("Join free when a free tier exists", () => {
48
+ expect(communityJoinOffer([paid, free])).toMatchObject({ kind: "free", tier: { id: "tier-free" } });
49
+ });
50
+ it("otherwise the lowest price, compared per month", () => {
51
+ expect(communityJoinOffer([paid, yearly])).toMatchObject({ kind: "paid", tier: { id: "tier-yearly" }, amount: 60, cycle: "year" });
52
+ });
53
+ it("Apply when every tier is apply-to-join, and nothing with no tiers", () => {
54
+ expect(communityJoinOffer([apply])).toMatchObject({ kind: "apply" });
55
+ expect(communityJoinOffer([])).toBeNull();
56
+ });
57
+ });
58
+
59
+ describe("useCommunities", () => {
60
+ it("lists the profile's communities and names the only one", async () => {
61
+ installAdapter([get("/public/communities", () => [COMMUNITY])]);
62
+ const { result } = renderHook(() => useCommunities(), { wrapper: wrapper({ community: null }) });
63
+ await waitFor(() => expect(result.current.communities).toHaveLength(1));
64
+ expect(result.current.only?.slug).toBe("fan-club");
65
+ expect(calls("get", "/public/communities")[0].params).toEqual({ profileId: "profile-1" });
66
+ });
67
+ it("names none when there are several", async () => {
68
+ installAdapter([get("/public/communities", () => [COMMUNITY, OTHER_COMMUNITY])]);
69
+ const { result } = renderHook(() => useCommunities(), { wrapper: wrapper({ community: null }) });
70
+ await waitFor(() => expect(result.current.communities).toHaveLength(2));
71
+ expect(result.current.only).toBeNull();
72
+ });
73
+ });
74
+
75
+ describe("useCommunity", () => {
76
+ it("an anonymous visitor: the community, its tiers, and the offer", async () => {
77
+ installAdapter([detailRoute(() => ({ tiers: [free, paid], viewer: viewer({ state: "anonymous" }) }))]);
78
+ const { result } = renderHook(() => useCommunity(), { wrapper: wrapper() });
79
+ await waitFor(() => expect(result.current.tiers).toHaveLength(2));
80
+ expect(result.current.viewer?.state).toBe("anonymous");
81
+ expect(result.current.isMember).toBe(false);
82
+ expect(result.current.joinOffer?.kind).toBe("free");
83
+ expect(calls("get", /\/me$/)).toHaveLength(0);
84
+ });
85
+
86
+ it("a signed-in member: the viewer comes from /me, with the tier and the upgrade", async () => {
87
+ installAdapter([meRoute(() => member), detailRoute(() => ({ tiers: [free, paid] }))]);
88
+ const { result } = renderHook(() => useCommunity(), { wrapper: wrapper({ user: account() }) });
89
+ await waitFor(() => expect(result.current.isMember).toBe(true));
90
+ expect(result.current.currentTier?.id).toBe("tier-paid");
91
+ expect(result.current.hasUpgrade).toBe(true);
92
+ expect(result.current.canInteract).toBe(true);
93
+ });
94
+
95
+ it("pending: inside, no access, with the date applied", async () => {
96
+ const pending = viewer({ state: "pending", application: { id: "a-1", tierId: "tier-apply", status: "pending", createdAt: "2026-09-20T00:00:00.000Z" } });
97
+ installAdapter([meRoute(() => pending), detailRoute(() => ({ tiers: [apply] }))]);
98
+ const { result } = renderHook(() => useCommunity(), { wrapper: wrapper({ user: account() }) });
99
+ await waitFor(() => expect(result.current.isPending).toBe(true));
100
+ expect(result.current.isMember).toBe(false);
101
+ expect(result.current.pendingSince).toBe("2026-09-20T00:00:00.000Z");
102
+ });
103
+
104
+ it("an unknown community is notFound", async () => {
105
+ installAdapter([
106
+ get(/^\/public\/communities\/[^/]+$/, () => {
107
+ throw { status: 404 };
108
+ }),
109
+ ]);
110
+ const { result } = renderHook(() => useCommunity("nope"), { wrapper: wrapper({ community: null }) });
111
+ await waitFor(() => expect(result.current.notFound).toBe(true));
112
+ });
113
+ });
114
+
115
+ describe("useCommunityAbout", () => {
116
+ it("Join opens the sheet in place on a visitor, on the tier asked for", async () => {
117
+ installAdapter([detailRoute(() => ({ tiers: [free, paid] }))]);
118
+ const { result } = renderHook(() => useCommunityAbout({ tierId: "tier-paid" }), { wrapper: wrapper() });
119
+ await waitFor(() => expect(result.current.tiers).toHaveLength(2));
120
+ expect(result.current.action).toBe("join");
121
+ expect(result.current.sheetOpen).toBe(false);
122
+ act(() => result.current.primary());
123
+ expect(result.current.sheetOpen).toBe(true);
124
+ expect(result.current.sheetTierId).toBe("tier-paid");
125
+ });
126
+
127
+ it("an invite or a shareable link opens the sheet straight away", async () => {
128
+ installAdapter([detailRoute(() => ({ tiers: [free] }))]);
129
+ const invite = renderHook(() => useCommunityAbout({ invite: "tok" }), { wrapper: wrapper() });
130
+ const link = renderHook(() => useCommunityAbout({ join: "link" }), { wrapper: wrapper() });
131
+ expect(invite.result.current.sheetOpen).toBe(true);
132
+ expect(link.result.current.sheetOpen).toBe(true);
133
+ });
134
+
135
+ it("a member goes straight inside instead of Join", async () => {
136
+ installAdapter([meRoute(() => member), detailRoute(() => ({ tiers: [paid] }))]);
137
+ const navigated: string[] = [];
138
+ const { result } = renderHook(() => useCommunityAbout({ navigate: (href) => navigated.push(href) }), { wrapper: wrapper({ user: account() }) });
139
+ await waitFor(() => expect(result.current.action).toBe("enter"));
140
+ act(() => result.current.primary());
141
+ expect(navigated).toEqual(["/i/communities/fan-club/home"]);
142
+ expect(result.current.sheetOpen).toBe(false);
143
+ });
144
+ });
145
+
146
+ describe("useCommunityHome", () => {
147
+ it("reconciles a Stripe return once, for THIS community, inside it", async () => {
148
+ installAdapter([
149
+ post("/public/payments/subscriptions/confirm-latest", () => ({ status: "active" })),
150
+ meRoute(() => member),
151
+ detailRoute(() => ({ tiers: [paid] })),
152
+ ]);
153
+ const { result, rerender } = renderHook(() => useCommunityHome({ confirmSubscription: true }), { wrapper: wrapper({ user: account() }) });
154
+ await waitFor(() => expect(calls("post", "/public/payments/subscriptions/confirm-latest")).toHaveLength(1));
155
+ rerender();
156
+ expect(calls("post", "/public/payments/subscriptions/confirm-latest")).toHaveLength(1);
157
+ expect(calls("post", "/public/payments/subscriptions/confirm-latest")[0].body).toEqual({
158
+ profileId: "profile-1",
159
+ communityId: COMMUNITY.id,
160
+ });
161
+ await waitFor(() => expect(result.current.confirming).toBe(false));
162
+ });
163
+
164
+ it("REGRESSION: stops showing Confirming under React StrictMode (dev double mount)", async () => {
165
+ // The confirm used to report `isPending` from the mutation observer, which
166
+ // StrictMode's unmount and remount detaches, so the spinner never cleared
167
+ // although the request answered 200.
168
+ installAdapter([
169
+ post("/public/payments/subscriptions/confirm-latest", () => ({ status: "active" })),
170
+ meRoute(() => member),
171
+ detailRoute(() => ({ tiers: [paid] })),
172
+ ]);
173
+ const { result } = renderHook(() => useCommunityHome({ confirmSubscription: true }), {
174
+ wrapper: wrapper({ user: account() }),
175
+ reactStrictMode: true,
176
+ });
177
+ await waitFor(() => expect(calls("post", "/public/payments/subscriptions/confirm-latest")).toHaveLength(1));
178
+ await waitFor(() => expect(result.current.confirming).toBe(false));
179
+ expect(calls("post", "/public/payments/subscriptions/confirm-latest")).toHaveLength(1);
180
+ });
181
+
182
+ it("the opener every locked item uses opens the one upgrade sheet on the tier", async () => {
183
+ installAdapter([meRoute(() => member), detailRoute(() => ({ tiers: [paid] }))]);
184
+ const { result } = renderHook(() => useCommunityHome({ welcome: true }), { wrapper: wrapper({ user: account() }) });
185
+ await waitFor(() => expect(result.current.isMember).toBe(true));
186
+ expect(result.current.showUpgrade).toBe(true);
187
+ expect(result.current.welcomeVisible).toBe(true);
188
+ act(() => result.current.opener.openUpgrade("tier-gold"));
189
+ expect(result.current.upgradeOpen).toBe(true);
190
+ expect(result.current.upgradeTierId).toBe("tier-gold");
191
+ act(() => result.current.dismissWelcome());
192
+ expect(result.current.welcomeVisible).toBe(false);
193
+ });
194
+
195
+ it("offers Join, not Upgrade, to a visitor", async () => {
196
+ installAdapter([detailRoute(() => ({ tiers: [paid], viewer: viewer({ state: "anonymous" }) }))]);
197
+ const { result } = renderHook(() => useCommunityHome(), { wrapper: wrapper() });
198
+ await waitFor(() => expect(result.current.showJoin).toBe(true));
199
+ expect(result.current.showUpgrade).toBe(false);
200
+ expect(result.current.joinPath).toBe("/i/communities/fan-club");
201
+ });
202
+ });
203
+
204
+ describe("useMemberHomeNav", () => {
205
+ it("builds every tab under the community home, and none leads to the account page", async () => {
206
+ installAdapter([
207
+ get("/public/community/spaces", () => [{ id: "s-1", name: "Space", tiers: [] }]),
208
+ get("/public/chat/channels", () => [{ id: "c-1", name: "General", type: "public", description: null }]),
209
+ get("/public/post-collections", () => ({ data: [{ id: "col-1" }], total: 1, page: 1, pageSize: 10 })),
210
+ get("/public/scheduled-calls/mine", () => [{ id: "call-1" }]),
211
+ ]);
212
+ const { result } = renderHook(() => useMemberHomeNav({ currentPath: "/i/communities/fan-club/home/chat" }), { wrapper: wrapper() });
213
+ await waitFor(() => expect(result.current.isReady).toBe(true));
214
+ await waitFor(() => expect(result.current.visibleItems).toHaveLength(7));
215
+ const hrefs = Object.fromEntries(result.current.items.map((item) => [item.key, item.href]));
216
+ expect(hrefs).toEqual({
217
+ posts: "/i/communities/fan-club/home",
218
+ collections: "/i/communities/fan-club/home/collections",
219
+ spaces: "/i/communities/fan-club/home/spaces",
220
+ calendar: "/i/communities/fan-club/home/calendar",
221
+ chat: "/i/communities/fan-club/home/chat",
222
+ leaderboard: "/i/communities/fan-club/home/leaderboard",
223
+ notifications: "/i/communities/fan-club/home/notifications",
224
+ });
225
+ expect(result.current.activeKey).toBe("chat");
226
+ expect(JSON.stringify(result.current.items)).not.toContain("/i/account");
227
+ });
228
+
229
+ it("an explicit active tab wins over the path", async () => {
230
+ installAdapter([]);
231
+ const { result } = renderHook(() => useMemberHomeNav({ activeKey: "spaces", currentPath: "/elsewhere" }), { wrapper: wrapper() });
232
+ expect(result.current.activeKey).toBe("spaces");
233
+ });
234
+ });
235
+
236
+ describe("useMyMemberships", () => {
237
+ it("one entry per community, each opening its own community home", async () => {
238
+ installAdapter([
239
+ // The server's real envelope: `[{ community, membership }]`
240
+ // (services/membership/selfServe.ts `view`), never a flat row.
241
+ get("/public/memberships/mine", () => [
242
+ {
243
+ community: { id: COMMUNITY.id, name: "Fan Club", slug: "fan-club" },
244
+ membership: { id: "m-1", communityId: COMMUNITY.id, tierId: "t-1", tierName: "Free", status: "active", amount: null, currency: null },
245
+ },
246
+ {
247
+ community: { id: OTHER_COMMUNITY.id, name: "Producers Circle", slug: "producers" },
248
+ membership: {
249
+ id: "m-2",
250
+ communityId: OTHER_COMMUNITY.id,
251
+ tierId: "t-9",
252
+ tierName: "Pro",
253
+ status: "past_due",
254
+ currentPeriodEnd: "2026-10-01T00:00:00.000Z",
255
+ amount: 1500,
256
+ currency: "USD",
257
+ },
258
+ },
259
+ ]),
260
+ ]);
261
+ const { result } = renderHook(() => useMyMemberships(), { wrapper: wrapper({ user: account(), community: null }) });
262
+ await waitFor(() => expect(result.current.memberships).toHaveLength(2));
263
+ expect(result.current.memberships.map((m) => m.homePath)).toEqual(["/i/communities/fan-club/home", "/i/communities/producers/home"]);
264
+ expect(result.current.memberships[1]).toMatchObject({
265
+ id: "m-2",
266
+ tierId: "t-9",
267
+ tierName: "Pro",
268
+ periodEnd: "2026-10-01T00:00:00.000Z",
269
+ amount: 1500,
270
+ currency: "USD",
271
+ });
272
+ expect(result.current.memberships[1].access.hasAccess).toBe(true);
273
+ expect(result.current.memberships[1].access.paymentFailed).toBe(true);
274
+ });
275
+
276
+ it("asks nothing while signed out", async () => {
277
+ installAdapter([]);
278
+ renderHook(() => useMyMemberships(), { wrapper: wrapper({ community: null }) });
279
+ expect(calls("get", "/public/memberships/mine")).toHaveLength(0);
280
+ });
281
+
282
+ it("toMyMembershipEntry reads a trial", () => {
283
+ const future = new Date(Date.now() + 5 * 86_400_000).toISOString();
284
+ expect(toMyMembershipEntry({ id: "m", status: "active", trialEndsAt: future, community: { id: "c", name: "C", slug: "c" } }).inTrial).toBe(true);
285
+ });
286
+ });
@@ -0,0 +1,157 @@
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 { useCommunityUpgrade } from "../communities/useCommunityUpgrade";
5
+ import { account, calls, detailRoute, get, installAdapter, meRoute, post, restoreAdapter, tier, viewer, type Route } from "./communityHarness";
6
+ import { wrapper } from "./communityHarness";
7
+
8
+ const paystack = vi.hoisted(() => ({ open: vi.fn(async () => "opened" as const) }));
9
+ vi.mock("../../../utils/paystackCheckout", async (importOriginal) => ({
10
+ ...(await importOriginal<typeof import("../../../utils/paystackCheckout")>()),
11
+ openPaystackCheckout: paystack.open,
12
+ }));
13
+
14
+ /**
15
+ * The upgrade sheet, on both providers. Only tiers ABOVE the current one are
16
+ * offered; on Stripe a `changed` answer is done with nothing to pay; a bank
17
+ * challenge or a Paystack upgrade (cancel and recreate, card again) is a
18
+ * payment step, and the sheet knows `requiresCard` BEFORE the fan confirms.
19
+ */
20
+
21
+ const HOME = "/i/communities/fan-club/home";
22
+ let onUpgraded: ReturnType<typeof vi.fn>;
23
+ beforeEach(() => {
24
+ onUpgraded = vi.fn();
25
+ paystack.open.mockClear();
26
+ });
27
+ afterEach(restoreAdapter);
28
+
29
+ const basic = tier({ id: "tier-basic", name: "Basic", priceMonthly: 5 });
30
+ const gold = tier({ id: "tier-gold", name: "Gold", priceMonthly: 20, benefits: [{ id: "b1", title: "Live calls" }] as never });
31
+ const platinum = tier({ id: "tier-plat", name: "Platinum", priceMonthly: 50 });
32
+
33
+ const member = () =>
34
+ viewer({
35
+ state: "member",
36
+ membership: {
37
+ id: "m-1",
38
+ tierId: "tier-basic",
39
+ tierName: "Basic",
40
+ status: "active",
41
+ cancelAtPeriodEnd: false,
42
+ currentPeriodEnd: "2026-10-27T00:00:00.000Z",
43
+ pendingDowngradeTierId: null,
44
+ },
45
+ upgradeTiers: [
46
+ { id: "tier-gold", name: "Gold", priceMonthly: 20, priceYearly: null },
47
+ { id: "tier-plat", name: "Platinum", priceMonthly: 50, priceYearly: null },
48
+ ],
49
+ });
50
+
51
+ const base = (extra: Route[] = [], preview = { amount: 15, currency: "USD", interval: "month", requiresCard: false, prorated: true }): Route[] => [
52
+ ...extra,
53
+ get("/public/communities/fan-club/upgrade-preview", () => preview),
54
+ meRoute(member),
55
+ detailRoute(() => ({ tiers: [basic, gold, platinum] })),
56
+ ];
57
+
58
+ describe("useCommunityUpgrade", () => {
59
+ it("offers only the tiers above the current one, enriched with the tier's benefits", async () => {
60
+ installAdapter(base());
61
+ const { result } = renderHook(() => useCommunityUpgrade({ onUpgraded }), { wrapper: wrapper({ user: account() }) });
62
+ await waitFor(() => expect(result.current.options).toHaveLength(2));
63
+ expect(result.current.options.map((option) => option.id)).toEqual(["tier-gold", "tier-plat"]);
64
+ expect(result.current.options[0].tier?.benefits?.[0]?.title).toBe("Live calls");
65
+ expect(result.current.step).toBe("choose");
66
+ expect(result.current.currentTierName).toBe("Basic");
67
+ });
68
+
69
+ it("Stripe: previews the prorated charge, then one tap moves the plan and lands on the home", async () => {
70
+ installAdapter(base([post("/public/communities/fan-club/upgrade", () => ({ status: "changed", membership: null }))]));
71
+ const { result } = renderHook(() => useCommunityUpgrade({ onUpgraded }), { wrapper: wrapper({ user: account() }) });
72
+ await waitFor(() => expect(result.current.options).toHaveLength(2));
73
+ act(() => result.current.select("tier-gold"));
74
+ expect(result.current.step).toBe("confirm");
75
+ await waitFor(() => expect(result.current.preview?.amount).toBe(15));
76
+ expect(result.current.prorated).toBe(true);
77
+ expect(result.current.requiresCard).toBe(false);
78
+ expect(calls("get", "/public/communities/fan-club/upgrade-preview")[0].params).toMatchObject({ membershipTierId: "tier-gold" });
79
+
80
+ await act(async () => {
81
+ await result.current.upgrade();
82
+ });
83
+ expect(calls("post", "/public/communities/fan-club/upgrade")[0].body).toEqual({
84
+ profileId: "profile-1",
85
+ membershipTierId: "tier-gold",
86
+ returnUrl: `${window.location.origin}${HOME}?confirmSubscription=1`,
87
+ });
88
+ expect(result.current.step).toBe("done");
89
+ expect(onUpgraded).toHaveBeenCalledWith({ requiresConfirmation: false, path: HOME });
90
+ });
91
+
92
+ it("Stripe: a bank challenge is a payment step, and success lands on the home to reconcile", async () => {
93
+ installAdapter(
94
+ base([post("/public/communities/fan-club/upgrade", () => ({ status: "requires_payment", clientSecret: "pi_up_secret", membership: null }))]),
95
+ );
96
+ const { result } = renderHook(() => useCommunityUpgrade({ onUpgraded, initialTierId: "tier-plat" }), { wrapper: wrapper({ user: account() }) });
97
+ await waitFor(() => expect(result.current.selected?.id).toBe("tier-plat"));
98
+ await act(async () => {
99
+ await result.current.upgrade();
100
+ });
101
+ expect(result.current.step).toBe("payment");
102
+ expect(result.current.clientSecret).toBe("pi_up_secret");
103
+ expect(result.current.isPaystack).toBe(false);
104
+ act(() => result.current.onPaymentSucceeded());
105
+ expect(onUpgraded).toHaveBeenCalledWith({ requiresConfirmation: true, path: `${HOME}?confirmSubscription=1` });
106
+ });
107
+
108
+ it("Paystack: requiresCard is known before confirming, and the upgrade opens the new checkout", async () => {
109
+ installAdapter(
110
+ base(
111
+ [
112
+ post("/public/communities/fan-club/upgrade", () => ({
113
+ status: "requires_payment",
114
+ accessCode: "ac_up",
115
+ checkoutUrl: "https://paystack.test/ac_up",
116
+ membership: null,
117
+ })),
118
+ ],
119
+ { amount: 20, currency: "NGN", interval: "month", requiresCard: true, prorated: false },
120
+ ),
121
+ );
122
+ const { result } = renderHook(() => useCommunityUpgrade({ onUpgraded }), { wrapper: wrapper({ user: account() }) });
123
+ await waitFor(() => expect(result.current.options).toHaveLength(2));
124
+ act(() => result.current.select("tier-gold"));
125
+ await waitFor(() => expect(result.current.requiresCard).toBe(true));
126
+ expect(result.current.prorated).toBe(false);
127
+
128
+ await act(async () => {
129
+ await result.current.upgrade();
130
+ });
131
+ expect(result.current.step).toBe("payment");
132
+ expect(result.current.isPaystack).toBe(true);
133
+ expect(paystack.open).toHaveBeenCalledWith(
134
+ expect.objectContaining({ accessCode: "ac_up", returnUrl: `${window.location.origin}${HOME}?confirmSubscription=1` }),
135
+ expect.anything(),
136
+ );
137
+ expect(onUpgraded).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it("a refusal is shown and nothing lands", async () => {
141
+ installAdapter(
142
+ base([
143
+ post("/public/communities/fan-club/upgrade", () => {
144
+ throw { status: 400, message: "That tier is not above yours." };
145
+ }),
146
+ ]),
147
+ );
148
+ const { result } = renderHook(() => useCommunityUpgrade({ onUpgraded, initialTierId: "tier-gold" }), { wrapper: wrapper({ user: account() }) });
149
+ await waitFor(() => expect(result.current.options).toHaveLength(2));
150
+ await act(async () => {
151
+ await result.current.upgrade();
152
+ });
153
+ expect(result.current.error).toBe("That tier is not above yours.");
154
+ expect(result.current.step).toBe("confirm");
155
+ expect(onUpgraded).not.toHaveBeenCalled();
156
+ });
157
+ });
@@ -17,7 +17,7 @@ export interface UseSignupFormOptions {
17
17
  * Headless account signup. Bring your own UI (or use the styled <SignupForm>).
18
18
  *
19
19
  * TWO STEPS: `submit()` creates the account and the platform emails a 6-digit
20
- * code — no session yet. `awaitingCode` then flips true; collect the code into
20
+ * code: no session yet. `awaitingCode` then flips true; collect the code into
21
21
  * `code` and call `submitCode()`, which signs the fan in and fires `onSuccess`.
22
22
  * Render the code input whenever `awaitingCode` is true.
23
23
  */
@@ -69,7 +69,7 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
69
69
  acceptedTerms: true,
70
70
  acceptedPrivacy: true,
71
71
  });
72
- // No session yet — the emailed code is the next step.
72
+ // No session yet: the emailed code is the next step.
73
73
  setAwaitingCode(true);
74
74
  } catch (e) {
75
75
  setError(msg(e) || "Could not create your account.");
@@ -104,6 +104,13 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
104
104
  }
105
105
  };
106
106
 
107
+ /** Leave the code step to correct the details (a mistyped email). */
108
+ const editDetails = () => {
109
+ setAwaitingCode(false);
110
+ setCode("");
111
+ setError(null);
112
+ };
113
+
107
114
  /** Re-send a code (throttled to one per 60s server-side). */
108
115
  const resendCode = async () => {
109
116
  setError(null);
@@ -146,5 +153,6 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
146
153
  setCode,
147
154
  submitCode,
148
155
  resendCode,
156
+ editDetails,
149
157
  };
150
158
  }
@@ -0,0 +1,67 @@
1
+ import type { ReactNode } from "react";
2
+ import { useOptionalCommunityContext, MissingCommunityProviderError } from "../../../contexts/CommunityContext";
3
+ import type { CommunityViewerState } from "../../../data/queries/useCommunities";
4
+ import { useCommunity, viewerHasAccess } from "./useCommunity";
5
+
6
+ export interface CommunityGateState {
7
+ hasAccess: boolean;
8
+ /** Anonymous visitors see the fallback too; this tells a sign-in prompt from an upgrade prompt. */
9
+ isAuthenticated: boolean;
10
+ /** Where the viewer stands in THIS community. */
11
+ viewerState: CommunityViewerState["state"] | null;
12
+ /** The tiers that unlock the content, as given. */
13
+ tiers: string[];
14
+ }
15
+
16
+ export interface CommunityGateProps {
17
+ /**
18
+ * Tier ids (of THIS community) that unlock the content. Omitted or empty:
19
+ * any active member of this community. Never "a member of anything on the
20
+ * profile": a membership in another community of the same creator unlocks
21
+ * nothing here.
22
+ */
23
+ tiers?: string[];
24
+ /** Override the decision, e.g. with a server-computed `hasAccess` on the item. */
25
+ access?: boolean;
26
+ children: ReactNode;
27
+ /** Rendered when locked: a node, or a render function given the gate state. */
28
+ fallback?: ReactNode | ((state: CommunityGateState) => ReactNode);
29
+ /** Rendered while the viewer is loading. Default nothing, so locked content never flashes. */
30
+ loading?: ReactNode;
31
+ }
32
+
33
+ /**
34
+ * The gate's rule, pure: an active membership IN THIS COMMUNITY, on one of
35
+ * `tiers` when tiers are named. `past_due` keeps access through the grace
36
+ * window, exactly as the server does.
37
+ */
38
+ export function communityGateAllows(
39
+ viewer: Pick<CommunityViewerState, "state" | "membership"> | null | undefined,
40
+ tiers?: string[],
41
+ ): boolean {
42
+ if (!viewerHasAccess(viewer)) return false;
43
+ if (!tiers?.length) return true;
44
+ return !!viewer?.membership && tiers.includes(viewer.membership.tierId);
45
+ }
46
+
47
+ /**
48
+ * Headless access gate for member content (replaces `MembershipGate`). Renders
49
+ * children when the viewer has access to the surrounding community's content,
50
+ * otherwise the fallback. Must sit inside `<CommunityProvider>`.
51
+ */
52
+ export function CommunityGate({ tiers, access, children, fallback, loading }: CommunityGateProps) {
53
+ if (!useOptionalCommunityContext()) throw new MissingCommunityProviderError("<CommunityGate>");
54
+ const community = useCommunity();
55
+ const viewer = community.viewer;
56
+ if (access === undefined && community.isLoading) return <>{loading ?? null}</>;
57
+ const hasAccess = access ?? communityGateAllows(viewer, tiers);
58
+ if (hasAccess) return <>{children}</>;
59
+ const state: CommunityGateState = {
60
+ hasAccess,
61
+ isAuthenticated: !!viewer && viewer.state !== "anonymous",
62
+ viewerState: viewer?.state ?? null,
63
+ tiers: tiers ?? [],
64
+ };
65
+ if (typeof fallback === "function") return <>{fallback(state)}</>;
66
+ return <>{fallback ?? null}</>;
67
+ }
@@ -0,0 +1,55 @@
1
+ // Communities: every page of a community has a headless version (decision 14).
2
+ // A styled component is one of these hooks plus markup and nothing else, so a
3
+ // creator can rebuild the list, the About page, the join sheet, the pending
4
+ // screen, the upgrade sheet and the membership menu on their own routes.
5
+ export {
6
+ CommunityProvider,
7
+ useCommunityContext,
8
+ useOptionalCommunityContext,
9
+ isCommunityId,
10
+ MissingCommunityProviderError,
11
+ CommunityUpgradeOpenerProvider,
12
+ useCommunityUpgradeOpener,
13
+ type CommunityContextValue,
14
+ type CommunityProviderProps,
15
+ type CommunityUpgradeOpener,
16
+ } from "../../../contexts/CommunityContext";
17
+ export {
18
+ useCommunities,
19
+ useCommunity,
20
+ communityJoinOffer,
21
+ viewerHasAccess,
22
+ COMMUNITY_ACCESS_STATES,
23
+ type CommunityJoinOffer,
24
+ type UseCommunityOptions,
25
+ } from "./useCommunity";
26
+ export {
27
+ useCommunityInvite,
28
+ describeCommunityInvite,
29
+ type CommunityInviteProblem,
30
+ type CommunityInviteSummary,
31
+ } from "./useCommunityInvite";
32
+ export {
33
+ useCommunityJoin,
34
+ communityJoinStep,
35
+ tierHasChoice,
36
+ type CommunityJoinStep,
37
+ type CommunityJoinStepInput,
38
+ type UseCommunityJoinOptions,
39
+ } from "./useCommunityJoin";
40
+ export {
41
+ useCommunityUpgrade,
42
+ type CommunityUpgradeStep,
43
+ type CommunityUpgradeOption,
44
+ type UseCommunityUpgradeOptions,
45
+ } from "./useCommunityUpgrade";
46
+ export { useCommunityMembership, communityDowngradeTiers } from "./useCommunityMembership";
47
+ export {
48
+ useCommunityAbout,
49
+ type CommunityAboutAction,
50
+ type UseCommunityAboutOptions,
51
+ } from "./useCommunityAbout";
52
+ export { useCommunityHome, type UseCommunityHomeOptions } from "./useCommunityHome";
53
+ export { useMyMemberships, toMyMembershipEntry, type MyMembershipEntry } from "./useMyMemberships";
54
+ export { CommunityGate, communityGateAllows, type CommunityGateProps, type CommunityGateState } from "./CommunityGate";
55
+ export { communitiesListPath, communityAboutPath, communityHomePath } from "./paths";
@@ -0,0 +1,36 @@
1
+ import { internalPath } from "../../../runtime/pages";
2
+
3
+ /**
4
+ * The hosted community pages, as plain paths. Built through `internalPath` so a
5
+ * page rename is a type error here rather than a dead link on a published site.
6
+ *
7
+ * Every flow that ends (a join, a paid join's Stripe return, an upgrade, an
8
+ * application) lands on the community HOME, never on `/i/account`.
9
+ */
10
+
11
+ type Query = Record<string, string | number | null | undefined>;
12
+
13
+ function withQuery(path: string, query?: Query): string {
14
+ if (!query) return path;
15
+ const params = new URLSearchParams();
16
+ for (const [key, value] of Object.entries(query)) {
17
+ if (value !== undefined && value !== null && value !== "") params.set(key, String(value));
18
+ }
19
+ const qs = params.toString();
20
+ return qs ? `${path}?${qs}` : path;
21
+ }
22
+
23
+ /** Every community on the profile: `/i/communities`. */
24
+ export function communitiesListPath(): string {
25
+ return internalPath("communities");
26
+ }
27
+
28
+ /** A community's About page, e.g. `communityAboutPath("fan-club", { tier })`. */
29
+ export function communityAboutPath(slug: string, query?: { invite?: string; join?: string; tier?: string }): string {
30
+ return withQuery(internalPath("communities/$slug", { slug }), query);
31
+ }
32
+
33
+ /** The inside of a community. `welcome=1` after a join, `confirmSubscription=1` after a Stripe return. */
34
+ export function communityHomePath(slug: string, query?: { welcome?: 1 | "1"; confirmSubscription?: 1 | "1" }): string {
35
+ return withQuery(internalPath("communities/$slug/home", { slug }), query);
36
+ }