@tribe-nest/forge 3.59.0 → 3.61.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 (266) hide show
  1. package/package.json +2 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CartContext.tsx +34 -1
  4. package/src/contexts/CommunityContext.tsx +192 -0
  5. package/src/contexts/PublicAuthContext.tsx +15 -3
  6. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  7. package/src/data/queries/useAccount.ts +33 -2
  8. package/src/data/queries/useAnalytics.ts +6 -6
  9. package/src/data/queries/useCallChat.ts +2 -2
  10. package/src/data/queries/useChat.ts +52 -40
  11. package/src/data/queries/useCheckouts.ts +2 -1
  12. package/src/data/queries/useCoachingAvailability.ts +10 -1
  13. package/src/data/queries/useCommunities.ts +345 -0
  14. package/src/data/queries/useCommunity.ts +450 -102
  15. package/src/data/queries/useCommunityAgent.ts +247 -0
  16. package/src/data/queries/useCommunityCalendar.ts +150 -0
  17. package/src/data/queries/useCommunityClassroom.ts +75 -0
  18. package/src/data/queries/useCommunityMembers.ts +108 -0
  19. package/src/data/queries/useCommunityModeration.ts +327 -0
  20. package/src/data/queries/useCommunityWiki.ts +137 -0
  21. package/src/data/queries/useCourses.ts +2 -0
  22. package/src/data/queries/useEmailSignIn.ts +79 -0
  23. package/src/data/queries/useEvents.ts +6 -1
  24. package/src/data/queries/useForms.ts +5 -1
  25. package/src/data/queries/useLeadMagnet.ts +6 -2
  26. package/src/data/queries/useMembership.ts +32 -7
  27. package/src/data/queries/useMembershipCalls.ts +116 -7
  28. package/src/data/queries/useMyBookings.ts +24 -1
  29. package/src/data/queries/useMyTickets.ts +24 -1
  30. package/src/data/queries/useNotifications.ts +6 -2
  31. package/src/data/queries/useOrders.ts +2 -1
  32. package/src/data/queries/usePWA.ts +111 -35
  33. package/src/data/queries/usePostCollections.ts +28 -16
  34. package/src/data/queries/usePosts.ts +16 -14
  35. package/src/data/queries/useSiteApp.ts +130 -0
  36. package/src/data/queries/useSubscriptions.ts +28 -9
  37. package/src/i18n/de.json +891 -2
  38. package/src/i18n/en.json +891 -2
  39. package/src/index.ts +36 -7
  40. package/src/runtime/pages.spec.ts +36 -0
  41. package/src/runtime/pages.ts +52 -27
  42. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  43. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  44. package/src/types/models.ts +11 -0
  45. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  46. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  47. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  48. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  49. package/src/ui/analytics/useMetaEvents.ts +78 -0
  50. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  51. package/src/ui/format/membershipGate.ts +19 -15
  52. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  53. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  54. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  55. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  56. package/src/ui/headless/_tests/communityHarness.tsx +212 -0
  57. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  58. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  59. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  60. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  61. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  62. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  63. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  64. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  65. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  66. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
  67. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
  68. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  69. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
  70. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  71. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  72. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  73. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  74. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  75. package/src/ui/headless/auth/useSignupForm.ts +30 -3
  76. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  77. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  78. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  79. package/src/ui/headless/communities/index.ts +96 -0
  80. package/src/ui/headless/communities/paths.ts +36 -0
  81. package/src/ui/headless/communities/useCommunity.ts +140 -0
  82. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  83. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  84. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  85. package/src/ui/headless/communities/useCommunityHome.ts +91 -0
  86. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  87. package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
  88. package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
  89. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  90. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  91. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  92. package/src/ui/headless/community/communityPoll.ts +297 -0
  93. package/src/ui/headless/community/countries.ts +123 -0
  94. package/src/ui/headless/community/index.ts +81 -1
  95. package/src/ui/headless/community/linkPreview.ts +71 -0
  96. package/src/ui/headless/community/richLexical.ts +27 -2
  97. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  98. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  99. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  100. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  101. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  102. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  103. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  104. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  105. package/src/ui/headless/downloads/index.ts +8 -0
  106. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  107. package/src/ui/headless/index.ts +21 -4
  108. package/src/ui/headless/memberHome/index.ts +11 -1
  109. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  110. package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
  111. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  112. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  113. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  114. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  115. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  116. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  117. package/src/ui/headless/portal/index.ts +57 -0
  118. package/src/ui/headless/portal/usePortal.ts +274 -0
  119. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  120. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  121. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  122. package/src/ui/headless/posts/upsell.ts +54 -8
  123. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  124. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  125. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  126. package/src/ui/headless/posts/usePostItem.ts +6 -5
  127. package/src/ui/headless/siteApp/appBar.ts +102 -0
  128. package/src/ui/headless/siteApp/appHome.ts +344 -0
  129. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  130. package/src/ui/headless/siteApp/index.ts +58 -0
  131. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  132. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  133. package/src/ui/headless/work/index.ts +2 -0
  134. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  135. package/src/ui/index.ts +28 -12
  136. package/src/ui/styled/AccountDashboard.tsx +754 -602
  137. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  138. package/src/ui/styled/ChatRoom.tsx +126 -16
  139. package/src/ui/styled/Checkout.tsx +8 -0
  140. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  141. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  142. package/src/ui/styled/CoachingDetail.tsx +2 -0
  143. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  144. package/src/ui/styled/CourseDetail.tsx +2 -0
  145. package/src/ui/styled/DownloadPage.tsx +263 -0
  146. package/src/ui/styled/EventConfirmation.tsx +10 -0
  147. package/src/ui/styled/EventDetail.tsx +10 -0
  148. package/src/ui/styled/FilmLibrary.tsx +22 -9
  149. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  150. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  151. package/src/ui/styled/MembershipTiers.tsx +49 -11
  152. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  153. package/src/ui/styled/Paywall.tsx +1 -1
  154. package/src/ui/styled/ProductDetail.tsx +12 -0
  155. package/src/ui/styled/PwaRegistration.tsx +5 -1
  156. package/src/ui/styled/SignupForm.tsx +18 -3
  157. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
  158. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  159. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  160. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  161. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  162. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  163. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  164. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  165. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  166. package/src/ui/styled/app/AppBar.tsx +298 -0
  167. package/src/ui/styled/app/AppHome.tsx +1139 -0
  168. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  169. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  170. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  171. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  172. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  173. package/src/ui/styled/app/index.ts +7 -0
  174. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  175. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  176. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  177. package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
  178. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  179. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  180. package/src/ui/styled/communities/CommunityHome.tsx +298 -0
  181. package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
  182. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
  183. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  184. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  185. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  186. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  187. package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
  188. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  189. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  190. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  191. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  192. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  193. package/src/ui/styled/communities/index.ts +19 -0
  194. package/src/ui/styled/communities/shared.tsx +177 -0
  195. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  196. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  197. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  198. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  199. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  200. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  201. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  202. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  203. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  204. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  205. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  206. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  207. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  208. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  209. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  210. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  211. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  212. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  213. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  214. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  215. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  216. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  217. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  218. package/src/ui/styled/community/index.ts +37 -2
  219. package/src/ui/styled/forge-utilities.css +253 -0
  220. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  221. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  222. package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
  223. package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
  224. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  225. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  226. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  227. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  228. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  229. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  230. package/src/ui/styled/members/index.ts +8 -0
  231. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  232. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  233. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  234. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  235. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  236. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  237. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  238. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  239. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  240. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  241. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  242. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  243. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  244. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  245. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  246. package/src/ui/styled/portal/index.ts +13 -0
  247. package/src/ui/styled/portal/portalNav.ts +260 -0
  248. package/src/ui/styled/portal/shared.tsx +206 -0
  249. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  250. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  251. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  252. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  253. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  254. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  255. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  256. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  257. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  258. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  259. package/src/ui/styled/work/index.ts +1 -1
  260. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  261. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  262. package/src/utils/landing.ts +19 -6
  263. package/src/utils/metaEvents.ts +302 -0
  264. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  265. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  266. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
@@ -0,0 +1,295 @@
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
+ ]);
211
+ const { result } = renderHook(() => useMemberHomeNav({ currentPath: "/i/communities/fan-club/home/chat" }), { wrapper: wrapper() });
212
+ await waitFor(() => expect(result.current.isReady).toBe(true));
213
+ await waitFor(() => expect(result.current.visibleItems).toHaveLength(6));
214
+ const hrefs = Object.fromEntries(result.current.items.map((item) => [item.key, item.href]));
215
+ expect(hrefs).toEqual({
216
+ posts: "/i/communities/fan-club/home",
217
+ collections: "/i/communities/fan-club/home/collections",
218
+ spaces: "/i/communities/fan-club/home/spaces",
219
+ // Built, but hidden: signed out, so not a member.
220
+ calendar: "/i/communities/fan-club/home/calendar",
221
+ // Built, but hidden: signed out, so not a member.
222
+ classroom: "/i/communities/fan-club/home/classroom",
223
+ chat: "/i/communities/fan-club/home/chat",
224
+ leaderboard: "/i/communities/fan-club/home/leaderboard",
225
+ notifications: "/i/communities/fan-club/home/notifications",
226
+ // Built, but hidden: signed out, so not a member.
227
+ members: "/i/communities/fan-club/home/members",
228
+ // Built, but hidden: this community has no wiki pages listed.
229
+ wiki: "/i/communities/fan-club/home/wiki",
230
+ // Built, but hidden: this viewer does not moderate the community.
231
+ moderation: "/i/communities/fan-club/home/moderation",
232
+ });
233
+ expect(result.current.visibleItems.map((item) => item.key)).not.toContain("moderation");
234
+ expect(result.current.activeKey).toBe("chat");
235
+ expect(JSON.stringify(result.current.items)).not.toContain("/i/account");
236
+ });
237
+
238
+ it("an explicit active tab wins over the path", async () => {
239
+ installAdapter([]);
240
+ const { result } = renderHook(() => useMemberHomeNav({ activeKey: "spaces", currentPath: "/elsewhere" }), { wrapper: wrapper() });
241
+ expect(result.current.activeKey).toBe("spaces");
242
+ });
243
+ });
244
+
245
+ describe("useMyMemberships", () => {
246
+ it("one entry per community, each opening its own community home", async () => {
247
+ installAdapter([
248
+ // The server's real envelope: `[{ community, membership }]`
249
+ // (services/membership/selfServe.ts `view`), never a flat row.
250
+ get("/public/memberships/mine", () => [
251
+ {
252
+ community: { id: COMMUNITY.id, name: "Fan Club", slug: "fan-club" },
253
+ membership: { id: "m-1", communityId: COMMUNITY.id, tierId: "t-1", tierName: "Free", status: "active", amount: null, currency: null },
254
+ },
255
+ {
256
+ community: { id: OTHER_COMMUNITY.id, name: "Producers Circle", slug: "producers" },
257
+ membership: {
258
+ id: "m-2",
259
+ communityId: OTHER_COMMUNITY.id,
260
+ tierId: "t-9",
261
+ tierName: "Pro",
262
+ status: "past_due",
263
+ currentPeriodEnd: "2026-10-01T00:00:00.000Z",
264
+ amount: 1500,
265
+ currency: "USD",
266
+ },
267
+ },
268
+ ]),
269
+ ]);
270
+ const { result } = renderHook(() => useMyMemberships(), { wrapper: wrapper({ user: account(), community: null }) });
271
+ await waitFor(() => expect(result.current.memberships).toHaveLength(2));
272
+ expect(result.current.memberships.map((m) => m.homePath)).toEqual(["/i/communities/fan-club/home", "/i/communities/producers/home"]);
273
+ expect(result.current.memberships[1]).toMatchObject({
274
+ id: "m-2",
275
+ tierId: "t-9",
276
+ tierName: "Pro",
277
+ periodEnd: "2026-10-01T00:00:00.000Z",
278
+ amount: 1500,
279
+ currency: "USD",
280
+ });
281
+ expect(result.current.memberships[1].access.hasAccess).toBe(true);
282
+ expect(result.current.memberships[1].access.paymentFailed).toBe(true);
283
+ });
284
+
285
+ it("asks nothing while signed out", async () => {
286
+ installAdapter([]);
287
+ renderHook(() => useMyMemberships(), { wrapper: wrapper({ community: null }) });
288
+ expect(calls("get", "/public/memberships/mine")).toHaveLength(0);
289
+ });
290
+
291
+ it("toMyMembershipEntry reads a trial", () => {
292
+ const future = new Date(Date.now() + 5 * 86_400_000).toISOString();
293
+ expect(toMyMembershipEntry({ id: "m", status: "active", trialEndsAt: future, community: { id: "c", name: "C", slug: "c" } }).inTrial).toBe(true);
294
+ });
295
+ });
@@ -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
+ });
@@ -0,0 +1,224 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { renderHook, waitFor } from "@testing-library/react";
4
+ import {
5
+ defaultWikiPageSlug,
6
+ extractWikiToc,
7
+ parseWikiContent,
8
+ shapeWikiList,
9
+ useCommunityWiki,
10
+ useCommunityWikiPage,
11
+ wikiHeadingSlug,
12
+ } from "../communities/useCommunityWiki";
13
+ import { useMemberHomeNav } from "../memberHome/useMemberHomeNav";
14
+ import type { CommunityWikiPageSummary } from "../../../data/queries/useCommunityWiki";
15
+ import { PROFILE_ID, account, calls, get, installAdapter, restoreAdapter, wrapper } from "./communityHarness";
16
+
17
+ /**
18
+ * The community wiki as the site reads it. These pin the list shaping (what is
19
+ * readable, what is locked, what the About page shows), which page the Wiki tab
20
+ * opens on, the table of contents a page grows from its headings, and what the
21
+ * hooks SEND: the community named by slug, the profile, and nothing more.
22
+ */
23
+
24
+ afterEach(restoreAdapter);
25
+
26
+ const summary = (overrides: Partial<CommunityWikiPageSummary>): CommunityWikiPageSummary => ({
27
+ id: "page-x",
28
+ title: "Page",
29
+ slug: "page",
30
+ isPublic: false,
31
+ locked: false,
32
+ unlockTiers: [],
33
+ updatedAt: "2026-09-01T00:00:00.000Z",
34
+ ...overrides,
35
+ });
36
+
37
+ const PAGES = [
38
+ summary({
39
+ id: "p1",
40
+ slug: "vip-lounge",
41
+ title: "VIP lounge",
42
+ locked: true,
43
+ unlockTiers: [{ id: "tier-gold", name: "Gold" }],
44
+ }),
45
+ summary({ id: "p2", slug: "start-here", title: "Start here" }),
46
+ summary({ id: "p3", slug: "community-guidelines", title: "Community guidelines", isPublic: true }),
47
+ ];
48
+
49
+ const text = (value: string) => ({ type: "text", version: 1, text: value });
50
+ const heading = (tag: string, value: string) => ({ type: "heading", version: 1, tag, children: [text(value)] });
51
+ const paragraph = (value: string) => ({ type: "paragraph", version: 1, children: [text(value)] });
52
+ const doc = (...children: unknown[]) => ({ root: { type: "root", version: 1, children } });
53
+
54
+ describe("shapeWikiList", () => {
55
+ it("splits readable from locked, keeping the wiki's order", () => {
56
+ const shaped = shapeWikiList(PAGES);
57
+ expect(shaped.all.map((p) => p.slug)).toEqual(["vip-lounge", "start-here", "community-guidelines"]);
58
+ expect(shaped.readable.map((p) => p.slug)).toEqual(["start-here", "community-guidelines"]);
59
+ expect(shaped.locked.map((p) => p.slug)).toEqual(["vip-lounge"]);
60
+ });
61
+
62
+ it("keeps only public pages for the About page", () => {
63
+ expect(shapeWikiList(PAGES, { publicOnly: true }).all.map((p) => p.slug)).toEqual(["community-guidelines"]);
64
+ });
65
+
66
+ it("treats a missing or malformed list as empty", () => {
67
+ expect(shapeWikiList(undefined).all).toEqual([]);
68
+ expect(shapeWikiList({} as never).all).toEqual([]);
69
+ });
70
+ });
71
+
72
+ describe("defaultWikiPageSlug", () => {
73
+ it("opens the first page the viewer can read, skipping locked ones", () => {
74
+ expect(defaultWikiPageSlug(PAGES)).toBe("start-here");
75
+ });
76
+
77
+ it("is null when every page is locked, or there are none", () => {
78
+ expect(defaultWikiPageSlug([PAGES[0]])).toBeNull();
79
+ expect(defaultWikiPageSlug([])).toBeNull();
80
+ expect(defaultWikiPageSlug(undefined)).toBeNull();
81
+ });
82
+ });
83
+
84
+ describe("parseWikiContent", () => {
85
+ it("accepts the editor state as an object or as a JSON string", () => {
86
+ const state = doc(paragraph("hi"));
87
+ expect(parseWikiContent(state)?.root.type).toBe("root");
88
+ expect(parseWikiContent(JSON.stringify(state))?.root.type).toBe("root");
89
+ });
90
+
91
+ it("returns null for anything that is not an editor state", () => {
92
+ expect(parseWikiContent(null)).toBeNull();
93
+ expect(parseWikiContent("not json")).toBeNull();
94
+ expect(parseWikiContent(42)).toBeNull();
95
+ });
96
+ });
97
+
98
+ describe("extractWikiToc", () => {
99
+ it("lists the top-level headings in order, with their level", () => {
100
+ const toc = extractWikiToc(doc(heading("h2", "Getting started"), paragraph("..."), heading("h3", "Your profile")));
101
+ expect(toc).toEqual([
102
+ { id: "getting-started", text: "Getting started", level: 2 },
103
+ { id: "your-profile", text: "Your profile", level: 3 },
104
+ ]);
105
+ });
106
+
107
+ it("gives repeated titles unique anchors, and skips empty headings", () => {
108
+ const toc = extractWikiToc(doc(heading("h2", "FAQ"), heading("h2", " "), heading("h2", "FAQ")));
109
+ expect(toc.map((e) => e.id)).toEqual(["faq", "faq-2"]);
110
+ });
111
+
112
+ it("reads headings made of several text runs, and a string state", () => {
113
+ const multi = {
114
+ type: "heading",
115
+ version: 1,
116
+ tag: "h1",
117
+ children: [text("Hello "), { type: "link", version: 1, children: [text("world")] }],
118
+ };
119
+ expect(extractWikiToc(JSON.stringify(doc(multi)))).toEqual([{ id: "hello-world", text: "Hello world", level: 1 }]);
120
+ });
121
+
122
+ it("is empty for a page with no headings (the seeded pages are plain paragraphs)", () => {
123
+ expect(extractWikiToc(doc(paragraph("Welcome")))).toEqual([]);
124
+ });
125
+ });
126
+
127
+ describe("wikiHeadingSlug", () => {
128
+ it("makes a URL-safe anchor, keeping letters beyond ASCII", () => {
129
+ expect(wikiHeadingSlug("Membership & billing!")).toBe("membership-billing");
130
+ expect(wikiHeadingSlug("Über uns")).toBe("uber-uns");
131
+ expect(wikiHeadingSlug("???")).toBe("section");
132
+ });
133
+ });
134
+
135
+ describe("useCommunityWiki", () => {
136
+ it("asks for this community's wiki by slug, and shapes it", async () => {
137
+ installAdapter([get("/public/communities/fan-club/wiki", () => PAGES)]);
138
+ const { result } = renderHook(() => useCommunityWiki(), { wrapper: wrapper({ user: account() }) });
139
+ await waitFor(() => expect(result.current.pages).toHaveLength(3));
140
+ expect(result.current.defaultSlug).toBe("start-here");
141
+ expect(result.current.lockedPages[0].slug).toBe("vip-lounge");
142
+ expect(calls("get", "/public/communities/fan-club/wiki")[0].params).toEqual({ profileId: PROFILE_ID });
143
+ // No community home around it, so the upgrade leads to the About page with the tier chosen.
144
+ expect(result.current.unlock).toBeNull();
145
+ expect(result.current.unlockHref(PAGES[0])).toBe("/i/communities/fan-club?tier=tier-gold");
146
+ });
147
+
148
+ it("shows only public pages with publicOnly, signed out too", async () => {
149
+ installAdapter([get("/public/communities/fan-club/wiki", () => PAGES)]);
150
+ const { result } = renderHook(() => useCommunityWiki({ publicOnly: true }), { wrapper: wrapper() });
151
+ await waitFor(() => expect(result.current.pages).toHaveLength(1));
152
+ expect(result.current.readablePages[0].slug).toBe("community-guidelines");
153
+ });
154
+ });
155
+
156
+ describe("useCommunityWikiPage", () => {
157
+ it("loads one page with its table of contents", async () => {
158
+ installAdapter([
159
+ get("/public/communities/fan-club/wiki/start-here", () => ({
160
+ id: "p2",
161
+ title: "Start here",
162
+ slug: "start-here",
163
+ isPublic: false,
164
+ content: doc(heading("h2", "Welcome"), paragraph("..."), heading("h2", "Where to go next")),
165
+ updatedAt: "2026-09-01T00:00:00.000Z",
166
+ })),
167
+ ]);
168
+ const { result } = renderHook(() => useCommunityWikiPage("start-here"), { wrapper: wrapper({ user: account() }) });
169
+ await waitFor(() => expect(result.current.page?.title).toBe("Start here"));
170
+ expect(result.current.showToc).toBe(true);
171
+ expect(result.current.headingIds).toEqual(["welcome", "where-to-go-next"]);
172
+ expect(calls("get", "/public/communities/fan-club/wiki/start-here")[0].params).toEqual({ profileId: PROFILE_ID });
173
+ });
174
+
175
+ it("hides the table of contents under two headings", async () => {
176
+ installAdapter([
177
+ get("/public/communities/fan-club/wiki/faq", () => ({
178
+ id: "p4",
179
+ title: "FAQ",
180
+ slug: "faq",
181
+ isPublic: false,
182
+ content: doc(heading("h2", "Only one"), paragraph("...")),
183
+ updatedAt: "2026-09-01T00:00:00.000Z",
184
+ })),
185
+ ]);
186
+ const { result } = renderHook(() => useCommunityWikiPage("faq"), { wrapper: wrapper({ user: account() }) });
187
+ await waitFor(() => expect(result.current.page).not.toBeNull());
188
+ expect(result.current.toc).toHaveLength(1);
189
+ expect(result.current.showToc).toBe(false);
190
+ });
191
+
192
+ it("reports a page the viewer may not read as not found, without retrying", async () => {
193
+ installAdapter([
194
+ get("/public/communities/fan-club/wiki/vip-lounge", () => {
195
+ throw { status: 404, message: "Not found" };
196
+ }),
197
+ ]);
198
+ const { result } = renderHook(() => useCommunityWikiPage("vip-lounge"), { wrapper: wrapper({ user: account() }) });
199
+ await waitFor(() => expect(result.current.notFound).toBe(true));
200
+ expect(result.current.isError).toBe(false);
201
+ expect(calls("get", "/public/communities/fan-club/wiki/vip-lounge")).toHaveLength(1);
202
+ });
203
+ });
204
+
205
+ describe("the Wiki tab in the member home nav", () => {
206
+ it("shows once the community has a page this viewer may see", async () => {
207
+ installAdapter([get("/public/communities/fan-club/wiki", () => PAGES)]);
208
+ const { result } = renderHook(
209
+ () => useMemberHomeNav({ currentPath: "/i/communities/fan-club/home/wiki/start-here" }),
210
+ {
211
+ wrapper: wrapper({ user: account() }),
212
+ },
213
+ );
214
+ await waitFor(() => expect(result.current.visibleItems.map((i) => i.key)).toContain("wiki"));
215
+ expect(result.current.activeKey).toBe("wiki");
216
+ });
217
+
218
+ it("stays hidden with no pages", async () => {
219
+ installAdapter([get("/public/communities/fan-club/wiki", () => [])]);
220
+ const { result } = renderHook(() => useMemberHomeNav({}), { wrapper: wrapper({ user: account() }) });
221
+ await waitFor(() => expect(result.current.isReady).toBe(true));
222
+ expect(result.current.visibleItems.map((i) => i.key)).not.toContain("wiki");
223
+ });
224
+ });