@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,389 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import {
5
+ buildPollPayload,
6
+ emptyPollDraft,
7
+ pollHiddenReason,
8
+ pollOptionPercent,
9
+ pollVoteErrorCode,
10
+ sameAnswer,
11
+ useCommunityPoll,
12
+ useCommunityPollDraft,
13
+ validatePollDraft,
14
+ type CommunityPollDraft,
15
+ } from "../community/communityPoll";
16
+ import { useCommunitySpaceFeed } from "../community/useCommunitySpaceFeed";
17
+ import { wikiUnlockParts } from "../communities/useCommunityWiki";
18
+ import { useCommunitySpacePosts, type CommunityPollView, type CommunityPost } from "../../../data/queries/useCommunity";
19
+ import { COMMUNITY, calls, get, installAdapter, post, restoreAdapter, wrapper } from "./communityHarness";
20
+
21
+ /**
22
+ * Polls on community posts and the level locks, headless: the composer's
23
+ * checks (the server's limits, so a draft that passes is not refused for its
24
+ * shape), the body the draft becomes, the vote request and the caches it
25
+ * patches, and a level-locked space that is never asked for.
26
+ */
27
+
28
+ afterEach(restoreAdapter);
29
+
30
+ const NOW = new Date("2026-09-30T12:00:00Z").getTime();
31
+
32
+ const draft = (overrides: Partial<CommunityPollDraft> = {}): CommunityPollDraft => ({
33
+ ...emptyPollDraft(),
34
+ question: "Which day works?",
35
+ options: ["Monday", "Tuesday"],
36
+ ...overrides,
37
+ });
38
+
39
+ describe("validatePollDraft (the composer's checks)", () => {
40
+ it("passes a complete draft", () => {
41
+ const errors = validatePollDraft(draft(), NOW);
42
+ expect(errors.valid).toBe(true);
43
+ expect(errors).toMatchObject({ question: null, options: [null, null], optionsList: null, closesAt: null });
44
+ });
45
+
46
+ it("needs a question, and at most 200 characters of it", () => {
47
+ expect(validatePollDraft(draft({ question: " " }), NOW)).toMatchObject({ question: "question_required", valid: false });
48
+ expect(validatePollDraft(draft({ question: "x".repeat(201) }), NOW).question).toBe("question_too_long");
49
+ expect(validatePollDraft(draft({ question: "x".repeat(200) }), NOW).question).toBeNull();
50
+ });
51
+
52
+ it("needs 2 to 10 options", () => {
53
+ expect(validatePollDraft(draft({ options: ["Only"] }), NOW)).toMatchObject({ optionsList: "options_too_few", valid: false });
54
+ const eleven = Array.from({ length: 11 }, (_, i) => `Option ${i}`);
55
+ expect(validatePollDraft(draft({ options: eleven }), NOW).optionsList).toBe("options_too_many");
56
+ const ten = eleven.slice(0, 10);
57
+ expect(validatePollDraft(draft({ options: ten }), NOW).valid).toBe(true);
58
+ });
59
+
60
+ it("marks each empty, overlong or repeated option where it is", () => {
61
+ const errors = validatePollDraft(draft({ options: ["Yes", " ", "x".repeat(121), " yes ", "x".repeat(120)] }), NOW);
62
+ expect(errors.options).toEqual([null, "option_empty", "option_too_long", "option_duplicate", null]);
63
+ expect(errors.valid).toBe(false);
64
+ });
65
+
66
+ it("wants a close time in the future, or none", () => {
67
+ expect(validatePollDraft(draft({ closesAt: "2026-09-29T10:00" }), NOW).closesAt).toBe("closes_in_past");
68
+ expect(validatePollDraft(draft({ closesAt: "not a date" }), NOW).closesAt).toBe("closes_invalid");
69
+ expect(validatePollDraft(draft({ closesAt: "2026-10-02T10:00" }), NOW).closesAt).toBeNull();
70
+ });
71
+
72
+ it("needs a close time when the results wait for the close", () => {
73
+ expect(validatePollDraft(draft({ resultsVisibility: "after_close" }), NOW).closesAt).toBe("closes_required");
74
+ expect(
75
+ validatePollDraft(draft({ resultsVisibility: "after_close", closesAt: "2026-10-02T10:00" }), NOW).valid,
76
+ ).toBe(true);
77
+ });
78
+ });
79
+
80
+ describe("buildPollPayload (what the create-post call carries)", () => {
81
+ it("sends the trimmed question as the title and the poll beside it", () => {
82
+ const payload = buildPollPayload(
83
+ draft({
84
+ question: " Which day works? ",
85
+ options: [" Monday ", "Tuesday "],
86
+ multipleChoice: true,
87
+ closesAt: "2026-10-02T10:00",
88
+ resultsVisibility: "always",
89
+ }),
90
+ );
91
+ expect(payload).toEqual({
92
+ title: "Which day works?",
93
+ poll: {
94
+ options: ["Monday", "Tuesday"],
95
+ multipleChoice: true,
96
+ closesAt: new Date("2026-10-02T10:00").toISOString(),
97
+ resultsVisibility: "always",
98
+ },
99
+ });
100
+ });
101
+
102
+ it("no close time is null, and the default visibility is after_vote", () => {
103
+ expect(buildPollPayload(draft()).poll).toEqual({
104
+ options: ["Monday", "Tuesday"],
105
+ multipleChoice: false,
106
+ closesAt: null,
107
+ resultsVisibility: "after_vote",
108
+ });
109
+ });
110
+ });
111
+
112
+ describe("useCommunityPollDraft", () => {
113
+ it("keeps between 2 and 10 options", () => {
114
+ const { result } = renderHook(() => useCommunityPollDraft());
115
+ expect(result.current.draft.options).toHaveLength(2);
116
+ expect(result.current.canRemoveOption).toBe(false);
117
+ act(() => result.current.removeOption(0));
118
+ expect(result.current.draft.options).toHaveLength(2);
119
+ for (let i = 0; i < 12; i++) act(() => result.current.addOption());
120
+ expect(result.current.draft.options).toHaveLength(10);
121
+ expect(result.current.canAddOption).toBe(false);
122
+ act(() => result.current.removeOption(3));
123
+ expect(result.current.draft.options).toHaveLength(9);
124
+ });
125
+
126
+ it("shows errors only once a submit was tried", () => {
127
+ const { result } = renderHook(() => useCommunityPollDraft());
128
+ expect(result.current.showErrors).toBe(false);
129
+ let ok = true;
130
+ act(() => {
131
+ ok = result.current.attempt();
132
+ });
133
+ expect(ok).toBe(false);
134
+ expect(result.current.showErrors).toBe(true);
135
+ expect(result.current.errors.question).toBe("question_required");
136
+ });
137
+ });
138
+
139
+ describe("reading a poll", () => {
140
+ const poll = (overrides: Partial<CommunityPollView> = {}): CommunityPollView => ({
141
+ id: "poll-1",
142
+ multipleChoice: false,
143
+ closesAt: null,
144
+ closed: false,
145
+ resultsVisibility: "after_vote",
146
+ resultsVisible: false,
147
+ totalVoters: null,
148
+ myVotes: [],
149
+ options: [],
150
+ ...overrides,
151
+ });
152
+
153
+ it("percentages are of the voters, and hidden counts stay hidden", () => {
154
+ expect(pollOptionPercent(1, 3)).toBe(33);
155
+ expect(pollOptionPercent(0, 0)).toBe(0);
156
+ expect(pollOptionPercent(null, null)).toBeNull();
157
+ });
158
+
159
+ it("says why the counts are hidden", () => {
160
+ expect(pollHiddenReason(poll())).toBe("after_vote");
161
+ expect(pollHiddenReason(poll({ resultsVisibility: "after_close" }))).toBe("after_close");
162
+ expect(pollHiddenReason(poll({ resultsVisible: true }))).toBeNull();
163
+ });
164
+
165
+ it("names a failed vote by its status", () => {
166
+ const err = (status: number) => ({ response: { status } });
167
+ expect(pollVoteErrorCode(err(409))).toBe("closed");
168
+ expect(pollVoteErrorCode(err(400))).toBe("invalid");
169
+ expect(pollVoteErrorCode(err(403))).toBe("forbidden");
170
+ expect(pollVoteErrorCode(new Error("network"))).toBe("generic");
171
+ });
172
+
173
+ it("an answer is the same answer in any order", () => {
174
+ expect(sameAnswer(["a", "b"], ["b", "a"])).toBe(true);
175
+ expect(sameAnswer(["a"], ["a", "b"])).toBe(false);
176
+ });
177
+ });
178
+
179
+ // ── Voting, against the real data hooks ──────────────────────────────────────
180
+
181
+ const OPTION_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
182
+ const OPTION_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
183
+
184
+ const openPoll = (overrides: Partial<CommunityPollView> = {}): CommunityPollView => ({
185
+ id: "poll-1",
186
+ multipleChoice: false,
187
+ closesAt: null,
188
+ closed: false,
189
+ resultsVisibility: "after_vote",
190
+ resultsVisible: false,
191
+ totalVoters: null,
192
+ myVotes: [],
193
+ options: [
194
+ { id: OPTION_A, label: "Monday", position: 0, votes: null },
195
+ { id: OPTION_B, label: "Tuesday", position: 1, votes: null },
196
+ ],
197
+ ...overrides,
198
+ });
199
+
200
+ const feedPost = (poll: CommunityPollView): CommunityPost => ({
201
+ id: "post-1",
202
+ profileId: "profile-1",
203
+ spaceId: "space-1",
204
+ authorAccountId: "acct-2",
205
+ title: "Which day works?",
206
+ body: null,
207
+ bodyText: "",
208
+ isPinned: false,
209
+ isLocked: false,
210
+ replyCount: 0,
211
+ reactionCount: 0,
212
+ lastActivityAt: null,
213
+ createdAt: "2026-09-30T10:00:00.000Z",
214
+ author: null,
215
+ media: [],
216
+ reactions: [],
217
+ poll,
218
+ });
219
+
220
+ const page = (posts: CommunityPost[]) => ({
221
+ data: posts,
222
+ total: posts.length,
223
+ page: 1,
224
+ pageSize: 20,
225
+ hasNextPage: false,
226
+ nextPage: null,
227
+ });
228
+
229
+ /** The feed and a poll on its first post, sharing one query client. */
230
+ function useFeedAndPoll() {
231
+ const feed = useCommunitySpacePosts("space-1");
232
+ const first = feed.data?.pages[0]?.data[0];
233
+ const poll = useCommunityPoll(first ?? { id: "post-1", spaceId: "space-1", poll: null });
234
+ return { feed, first, poll };
235
+ }
236
+
237
+ describe("useCommunityPoll voting", () => {
238
+ it("sends the member's whole answer and puts the answer the server gives into the feed", async () => {
239
+ const answered = openPoll({
240
+ resultsVisible: true,
241
+ totalVoters: 4,
242
+ myVotes: [OPTION_B],
243
+ options: [
244
+ { id: OPTION_A, label: "Monday", position: 0, votes: 1 },
245
+ { id: OPTION_B, label: "Tuesday", position: 1, votes: 3 },
246
+ ],
247
+ });
248
+ installAdapter([
249
+ get("/public/community/spaces/space-1/posts", () => page([feedPost(openPoll())])),
250
+ post("/public/community/posts/post-1/poll/vote", () => answered),
251
+ ]);
252
+ const { result } = renderHook(() => useFeedAndPoll(), { wrapper: wrapper() });
253
+ await waitFor(() => expect(result.current.first?.poll?.id).toBe("poll-1"));
254
+ expect(result.current.poll.choosing).toBe(true);
255
+
256
+ // Single choice: picking another option replaces the first.
257
+ act(() => result.current.poll.toggle(OPTION_A));
258
+ act(() => result.current.poll.toggle(OPTION_B));
259
+ expect(result.current.poll.selection).toEqual([OPTION_B]);
260
+ await act(async () => {
261
+ await result.current.poll.submit();
262
+ });
263
+
264
+ expect(calls("post", "/public/community/posts/post-1/poll/vote").map((c) => c.body)).toEqual([
265
+ { profileId: "profile-1", communityId: COMMUNITY.id, optionIds: [OPTION_B] },
266
+ ]);
267
+ // The feed cache now holds the server's answer: counts shown, the vote on record.
268
+ await waitFor(() => expect(result.current.first?.poll).toEqual(answered));
269
+ expect(result.current.poll.hasVoted).toBe(true);
270
+ expect(result.current.poll.choosing).toBe(false);
271
+ });
272
+
273
+ it("withdrawing sends an empty answer", async () => {
274
+ installAdapter([
275
+ get("/public/community/spaces/space-1/posts", () =>
276
+ page([feedPost(openPoll({ myVotes: [OPTION_A], resultsVisible: true, totalVoters: 1 }))]),
277
+ ),
278
+ post("/public/community/posts/post-1/poll/vote", () => openPoll()),
279
+ ]);
280
+ const { result } = renderHook(() => useFeedAndPoll(), { wrapper: wrapper() });
281
+ await waitFor(() => expect(result.current.poll.hasVoted).toBe(true));
282
+ await act(async () => {
283
+ await result.current.poll.withdraw();
284
+ });
285
+ expect(calls("post", "/public/community/posts/post-1/poll/vote")[0].body).toMatchObject({ optionIds: [] });
286
+ await waitFor(() => expect(result.current.first?.poll?.myVotes).toEqual([]));
287
+ });
288
+
289
+ it("a multiple-choice poll collects several options", async () => {
290
+ installAdapter([
291
+ get("/public/community/spaces/space-1/posts", () => page([feedPost(openPoll({ multipleChoice: true }))])),
292
+ ]);
293
+ const { result } = renderHook(() => useFeedAndPoll(), { wrapper: wrapper() });
294
+ await waitFor(() => expect(result.current.poll.poll?.multipleChoice).toBe(true));
295
+ act(() => result.current.poll.toggle(OPTION_A));
296
+ act(() => result.current.poll.toggle(OPTION_B));
297
+ expect(result.current.poll.selection).toEqual([OPTION_A, OPTION_B]);
298
+ act(() => result.current.poll.toggle(OPTION_A));
299
+ expect(result.current.poll.selection).toEqual([OPTION_B]);
300
+ });
301
+
302
+ it("a 409 reads as closed, and the feed keeps the old poll", async () => {
303
+ installAdapter([
304
+ get("/public/community/spaces/space-1/posts", () => page([feedPost(openPoll())])),
305
+ post("/public/community/posts/post-1/poll/vote", () => {
306
+ throw { status: 409, message: "This poll has closed." };
307
+ }),
308
+ ]);
309
+ const { result } = renderHook(() => useFeedAndPoll(), { wrapper: wrapper() });
310
+ await waitFor(() => expect(result.current.poll.poll).not.toBeNull());
311
+ act(() => result.current.poll.toggle(OPTION_A));
312
+ await act(async () => {
313
+ await result.current.poll.submit();
314
+ });
315
+ expect(result.current.poll.error).toBe("closed");
316
+ expect(result.current.first?.poll?.myVotes).toEqual([]);
317
+ });
318
+
319
+ it("a closed poll takes no vote", async () => {
320
+ installAdapter([
321
+ get("/public/community/spaces/space-1/posts", () => page([feedPost(openPoll({ closed: true }))])),
322
+ ]);
323
+ const { result } = renderHook(() => useFeedAndPoll(), { wrapper: wrapper() });
324
+ await waitFor(() => expect(result.current.poll.closed).toBe(true));
325
+ expect(result.current.poll.canVote).toBe(false);
326
+ expect(result.current.poll.choosing).toBe(false);
327
+ act(() => result.current.poll.toggle(OPTION_A));
328
+ expect(result.current.poll.selection).toEqual([]);
329
+ });
330
+ });
331
+
332
+ // ── Level locks ──────────────────────────────────────────────────────────────
333
+
334
+ const space = (overrides: Record<string, unknown> = {}) => ({
335
+ id: "space-1",
336
+ profileId: "profile-1",
337
+ name: "Inner circle",
338
+ slug: "inner-circle",
339
+ description: null,
340
+ type: "feed",
341
+ courseId: null,
342
+ order: 0,
343
+ archivedAt: null,
344
+ createdAt: "2026-09-01T00:00:00.000Z",
345
+ tiers: [],
346
+ postCount: 3,
347
+ memberCount: 2,
348
+ isJoined: false,
349
+ minLevel: 1,
350
+ levelLocked: false,
351
+ ...overrides,
352
+ });
353
+
354
+ describe("useCommunitySpaceFeed and a level-locked space", () => {
355
+ it("is resolved from the list but its posts are never asked for", async () => {
356
+ installAdapter([get("/public/community/spaces", () => [space({ minLevel: 3, levelLocked: true })])]);
357
+ const { result } = renderHook(() => useCommunitySpaceFeed("inner-circle"), { wrapper: wrapper() });
358
+ await waitFor(() => expect(result.current.space?.id).toBe("space-1"));
359
+ expect(result.current.levelLocked).toBe(true);
360
+ expect(result.current.minLevel).toBe(3);
361
+ expect(calls("get", /\/posts$/)).toHaveLength(0);
362
+ });
363
+
364
+ it("an open space loads its posts", async () => {
365
+ installAdapter([
366
+ get("/public/community/spaces", () => [space()]),
367
+ get("/public/community/spaces/space-1/posts", () => page([])),
368
+ ]);
369
+ const { result } = renderHook(() => useCommunitySpaceFeed("space-1"), { wrapper: wrapper() });
370
+ await waitFor(() => expect(calls("get", "/public/community/spaces/space-1/posts")).toHaveLength(1));
371
+ expect(result.current.levelLocked).toBe(false);
372
+ });
373
+ });
374
+
375
+ describe("wikiUnlockParts", () => {
376
+ it("names the level, the tiers, or both", () => {
377
+ expect(wikiUnlockParts({ unlockLevel: 4, unlockTiers: [] })).toEqual({ level: 4, tiers: "" });
378
+ expect(wikiUnlockParts({ unlockLevel: 4, unlockTiers: [{ id: "t", name: "Gold" }] })).toEqual({
379
+ level: 4,
380
+ tiers: "Gold",
381
+ });
382
+ expect(wikiUnlockParts({ unlockLevel: null, unlockTiers: [{ id: "t", name: "Gold" }] })).toEqual({
383
+ level: null,
384
+ tiers: "Gold",
385
+ });
386
+ // An older server sends no unlockLevel at all.
387
+ expect(wikiUnlockParts({ unlockTiers: [] })).toEqual({ level: null, tiers: "" });
388
+ });
389
+ });
@@ -0,0 +1,151 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { render, renderHook, waitFor } from "@testing-library/react";
4
+ import { useCommunitySpaces, useCommunityLeaderboard } from "../../../data/queries/useCommunity";
5
+ import { useChatChannels } from "../../../data/queries/useChat";
6
+ import { useGetPosts, useGetSavedPosts } from "../../../data/queries/usePosts";
7
+ import { useGetPostCollection, useGetPostCollections } from "../../../data/queries/usePostCollections";
8
+ import { useMyMembershipCalls } from "../../../data/queries/useMembershipCalls";
9
+ import type { ReactNode } from "react";
10
+ import { CommunityProvider, useCommunityContext } from "../../../contexts/CommunityContext";
11
+ import { useMemberHomeNav } from "../memberHome/useMemberHomeNav";
12
+ import { useCommunityJoin } from "../communities/useCommunityJoin";
13
+ import { COMMUNITY, OTHER_COMMUNITY, calls, detailRoute, get, installAdapter, restoreAdapter, wrapper } from "./communityHarness";
14
+
15
+ /**
16
+ * `<CommunityProvider>` is how every member-content hook learns WHICH community
17
+ * it is reading. A profile runs several, each fully isolated, so a hook outside
18
+ * a provider must refuse (a developer error naming the provider) rather than
19
+ * fetch "the profile's" content, and inside one it must send the community and
20
+ * key its cache by it.
21
+ */
22
+
23
+ afterEach(restoreAdapter);
24
+
25
+ const PROVIDER_ERROR = /must be used inside <CommunityProvider/;
26
+
27
+ describe("outside a provider, every member-content hook refuses", () => {
28
+ const cases: Array<[string, () => unknown]> = [
29
+ ["useCommunitySpaces", () => useCommunitySpaces()],
30
+ ["useCommunityLeaderboard", () => useCommunityLeaderboard()],
31
+ ["useChatChannels", () => useChatChannels()],
32
+ ["useGetPosts", () => useGetPosts()],
33
+ ["useGetSavedPosts", () => useGetSavedPosts()],
34
+ ["useGetPostCollections", () => useGetPostCollections()],
35
+ ["useMyMembershipCalls", () => useMyMembershipCalls()],
36
+ ["useMemberHomeNav", () => useMemberHomeNav()],
37
+ ["useCommunityJoin", () => useCommunityJoin()],
38
+ ["useCommunityContext", () => useCommunityContext()],
39
+ ];
40
+ for (const [name, hook] of cases) {
41
+ it(`${name} throws a developer error that names the provider and the hook`, () => {
42
+ installAdapter([]);
43
+ expect(() => renderHook(hook, { wrapper: wrapper({ community: null }) })).toThrow(PROVIDER_ERROR);
44
+ expect(() => renderHook(hook, { wrapper: wrapper({ community: null }) })).toThrow(name);
45
+ // Nothing was fetched on the way to the error.
46
+ expect(calls("get", /\/public\/(community|chat|posts|post-collections|scheduled-calls)/)).toHaveLength(0);
47
+ });
48
+ }
49
+
50
+ it("a course-scoped call read needs no provider: a course belongs to no community", async () => {
51
+ installAdapter([get("/public/scheduled-calls/mine", () => [])]);
52
+ const { result } = renderHook(() => useMyMembershipCalls({ courseId: "course-1" }), { wrapper: wrapper({ community: null }) });
53
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
54
+ const [req] = calls("get", "/public/scheduled-calls/mine");
55
+ expect(req.params).toMatchObject({ courseId: "course-1" });
56
+ expect(req.params?.communityId).toBeUndefined();
57
+ });
58
+ });
59
+
60
+ describe("inside a provider, the community is sent and keys the cache", () => {
61
+ it("sends communityId with every member-content read", async () => {
62
+ installAdapter([
63
+ get("/public/community/spaces", () => []),
64
+ get("/public/chat/channels", () => []),
65
+ get("/public/posts", () => ({ data: [], total: 0, page: 1, pageSize: 10, hasNextPage: false })),
66
+ get("/public/post-collections", () => ({ data: [], total: 0, page: 1, pageSize: 10 })),
67
+ get("/public/scheduled-calls/mine", () => []),
68
+ ]);
69
+ const { result } = renderHook(
70
+ () => ({
71
+ spaces: useCommunitySpaces(),
72
+ channels: useChatChannels(),
73
+ posts: useGetPosts(),
74
+ collections: useGetPostCollections(),
75
+ calls: useMyMembershipCalls(),
76
+ }),
77
+ { wrapper: wrapper() },
78
+ );
79
+ await waitFor(() => expect(Object.values(result.current).every((q) => q.isSuccess)).toBe(true));
80
+ for (const path of ["/public/community/spaces", "/public/chat/channels", "/public/posts", "/public/post-collections", "/public/scheduled-calls/mine"]) {
81
+ const [req] = calls("get", path);
82
+ expect(req?.params, path).toMatchObject({ profileId: "profile-1", communityId: COMMUNITY.id });
83
+ }
84
+ });
85
+
86
+ it("two communities on one page (one query cache) never share a cache entry", async () => {
87
+ installAdapter([
88
+ get("/public/community/spaces", (req) => [
89
+ { id: `space-of-${String(req.params?.communityId)}`, name: "Space", tiers: [] },
90
+ ]),
91
+ ]);
92
+ const seen: Record<string, string | undefined> = {};
93
+ function Spaces({ label }: { label: string }) {
94
+ seen[label] = useCommunitySpaces().data?.[0]?.id;
95
+ return null;
96
+ }
97
+ const Wrapper = wrapper({ community: null });
98
+ render(
99
+ <Wrapper>
100
+ <CommunityProvider community={COMMUNITY.slug} initialData={COMMUNITY}>
101
+ <Spaces label="a" />
102
+ </CommunityProvider>
103
+ <CommunityProvider community={OTHER_COMMUNITY.slug} initialData={OTHER_COMMUNITY}>
104
+ <Spaces label="b" />
105
+ </CommunityProvider>
106
+ </Wrapper>,
107
+ );
108
+ await waitFor(() => expect(seen.a && seen.b).toBeTruthy());
109
+ expect(seen.a).toBe(`space-of-${COMMUNITY.id}`);
110
+ expect(seen.b).toBe(`space-of-${OTHER_COMMUNITY.id}`);
111
+ expect(calls("get", "/public/community/spaces")).toHaveLength(2);
112
+ });
113
+
114
+ it("resolves a slug to the community's id before any member read goes out", async () => {
115
+ installAdapter([detailRoute(() => ({})), get("/public/community/spaces", () => [])]);
116
+ const Wrapper = wrapper({ community: null });
117
+ const { result } = renderHook(
118
+ () => {
119
+ const context = useCommunityContext();
120
+ const spaces = useCommunitySpaces();
121
+ return { context, spaces };
122
+ },
123
+ {
124
+ wrapper: ({ children }) => (
125
+ <Wrapper>
126
+ <ProviderBySlug>{children}</ProviderBySlug>
127
+ </Wrapper>
128
+ ),
129
+ },
130
+ );
131
+ expect(result.current.context.slug).toBe("fan-club");
132
+ await waitFor(() => expect(result.current.context.communityId).toBe(COMMUNITY.id));
133
+ await waitFor(() => expect(result.current.spaces.isSuccess).toBe(true));
134
+ expect(calls("get", "/public/community/spaces")[0].params).toMatchObject({ communityId: COMMUNITY.id });
135
+ });
136
+
137
+ it("a collection that is not in this community reads as not found, not as a crash", async () => {
138
+ installAdapter([
139
+ get(/^\/public\/post-collections\//, () => {
140
+ throw { status: 404 };
141
+ }),
142
+ ]);
143
+ const { result } = renderHook(() => useGetPostCollection("col-elsewhere"), { wrapper: wrapper() });
144
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
145
+ expect(result.current.data).toBeNull();
146
+ });
147
+ });
148
+
149
+ function ProviderBySlug({ children }: { children: ReactNode }) {
150
+ return <CommunityProvider community="fan-club">{children}</CommunityProvider>;
151
+ }
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildMemberHomeBar } from "../memberHome/memberHomeBar";
3
+ import type { MemberHomeNavKey } from "../memberHome/useMemberHomeNav";
4
+
5
+ /**
6
+ * The phone's bottom bar: four slots in a fixed priority (Feed, Spaces, Chat,
7
+ * Alerts), filled from the fallback order when one of those is not shown, then
8
+ * "More" with everything else, grouped. Every shown item is in exactly one
9
+ * place.
10
+ */
11
+
12
+ const items = (...keys: MemberHomeNavKey[]) => keys.map((key) => ({ key }));
13
+ const keys = (list: Array<{ key: MemberHomeNavKey }>) => list.map((i) => i.key);
14
+ const ALL: MemberHomeNavKey[] = [
15
+ "posts",
16
+ "collections",
17
+ "spaces",
18
+ "calendar",
19
+ "classroom",
20
+ "chat",
21
+ "leaderboard",
22
+ "notifications",
23
+ "members",
24
+ "wiki",
25
+ "moderation",
26
+ ];
27
+
28
+ describe("buildMemberHomeBar", () => {
29
+ it("everything shown: Feed, Spaces, Chat, Alerts in the bar; the rest grouped under More", () => {
30
+ const model = buildMemberHomeBar({ visibleItems: items(...ALL), activeKey: "posts" });
31
+ expect(keys(model.bar)).toEqual(["posts", "spaces", "chat", "notifications"]);
32
+ expect(model.more.map((g) => g.group)).toEqual(["community", "you"]);
33
+ expect(keys(model.more[0]!.items)).toEqual(["collections", "calendar", "classroom", "wiki", "members", "leaderboard"]);
34
+ expect(keys(model.more[1]!.items)).toEqual(["moderation"]);
35
+ expect(model.moreActive).toBe(false);
36
+ });
37
+
38
+ it("no chat: the next item in the fallback order (calendar) takes its slot", () => {
39
+ const visible = items("posts", "spaces", "calendar", "classroom", "notifications", "members");
40
+ const model = buildMemberHomeBar({ visibleItems: visible, activeKey: null });
41
+ expect(keys(model.bar)).toEqual(["posts", "spaces", "notifications", "calendar"]);
42
+ expect(keys(model.more[0]!.items)).toEqual(["classroom", "members"]);
43
+ });
44
+
45
+ it("no spaces (so no alerts either): calendar, classroom fill in, in that order", () => {
46
+ const model = buildMemberHomeBar({ visibleItems: items("posts", "chat", "calendar", "classroom", "members", "wiki"), activeKey: null });
47
+ expect(keys(model.bar)).toEqual(["posts", "chat", "calendar", "classroom"]);
48
+ expect(keys(model.more[0]!.items)).toEqual(["wiki", "members"]);
49
+ });
50
+
51
+ it("the fallback reaches collections, leaderboard and moderation only when nothing ahead is shown", () => {
52
+ const model = buildMemberHomeBar({ visibleItems: items("posts", "collections", "moderation"), activeKey: null });
53
+ expect(keys(model.bar)).toEqual(["posts", "collections", "moderation"]);
54
+ expect(model.more).toEqual([]);
55
+ });
56
+
57
+ it("the active page under More highlights More, not a bar item", () => {
58
+ const model = buildMemberHomeBar({ visibleItems: items(...ALL), activeKey: "wiki" });
59
+ expect(model.moreActive).toBe(true);
60
+ expect(buildMemberHomeBar({ visibleItems: items(...ALL), activeKey: "chat" }).moreActive).toBe(false);
61
+ expect(buildMemberHomeBar({ visibleItems: items(...ALL), activeKey: "moderation" }).moreActive).toBe(true);
62
+ });
63
+
64
+ it("an active key that is not shown highlights nothing", () => {
65
+ const model = buildMemberHomeBar({ visibleItems: items("posts", "spaces"), activeKey: "wiki" });
66
+ expect(model.moreActive).toBe(false);
67
+ });
68
+
69
+ it("every shown item is in exactly one place, and nothing hidden appears", () => {
70
+ for (let n = 1; n <= ALL.length; n++) {
71
+ const visible = items(...ALL.slice(0, n));
72
+ const model = buildMemberHomeBar({ visibleItems: visible, activeKey: null });
73
+ const placed = [...keys(model.bar), ...model.more.flatMap((g) => keys(g.items))];
74
+ expect(placed.sort()).toEqual(keys(visible).sort());
75
+ expect(model.bar.length).toBeLessThanOrEqual(4);
76
+ }
77
+ });
78
+
79
+ it("keeps the caller's item objects, so hrefs and active flags pass through", () => {
80
+ const posts = { key: "posts" as const, href: "/i/communities/x/home", active: true };
81
+ const model = buildMemberHomeBar({ visibleItems: [posts], activeKey: "posts" });
82
+ expect(model.bar[0]).toBe(posts);
83
+ });
84
+ });