@tribe-nest/forge 3.60.0 → 3.63.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 (220) hide show
  1. package/package.json +5 -2
  2. package/src/contexts/CartContext.tsx +34 -1
  3. package/src/contexts/PublicAuthContext.tsx +15 -3
  4. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  5. package/src/data/queries/useAccount.ts +33 -2
  6. package/src/data/queries/useAnalytics.ts +6 -6
  7. package/src/data/queries/useCallChat.ts +2 -2
  8. package/src/data/queries/useChat.ts +10 -1
  9. package/src/data/queries/useCheckouts.ts +2 -1
  10. package/src/data/queries/useCoachingAvailability.ts +10 -1
  11. package/src/data/queries/useCommunities.ts +2 -0
  12. package/src/data/queries/useCommunity.ts +362 -18
  13. package/src/data/queries/useCommunityAgent.ts +247 -0
  14. package/src/data/queries/useCommunityCalendar.ts +150 -0
  15. package/src/data/queries/useCommunityClassroom.ts +75 -0
  16. package/src/data/queries/useCommunityMembers.ts +108 -0
  17. package/src/data/queries/useCommunityModeration.ts +327 -0
  18. package/src/data/queries/useCommunityWiki.ts +137 -0
  19. package/src/data/queries/useCourses.ts +2 -0
  20. package/src/data/queries/useEmailSignIn.ts +79 -0
  21. package/src/data/queries/useEvents.ts +6 -1
  22. package/src/data/queries/useForms.ts +5 -1
  23. package/src/data/queries/useLeadMagnet.ts +6 -2
  24. package/src/data/queries/useMyBookings.ts +24 -1
  25. package/src/data/queries/useMyTickets.ts +24 -1
  26. package/src/data/queries/useNotifications.ts +6 -2
  27. package/src/data/queries/useOrders.ts +2 -1
  28. package/src/data/queries/usePWA.ts +111 -35
  29. package/src/data/queries/useSiteApp.ts +130 -0
  30. package/src/data/queries/useSubscriptions.ts +6 -1
  31. package/src/i18n/de.json +746 -1
  32. package/src/i18n/en.json +746 -1
  33. package/src/index.ts +20 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/seo/_tests/seo.spec.ts +326 -0
  37. package/src/seo/applySeoToHead.ts +98 -0
  38. package/src/seo/index.ts +9 -0
  39. package/src/seo/resolve.ts +211 -0
  40. package/src/seo/seoHead.ts +61 -0
  41. package/src/seo/siteSeoHead.ts +97 -0
  42. package/src/seo/types.ts +99 -0
  43. package/src/server/_tests/siteBootstrap.spec.ts +65 -0
  44. package/src/server/index.ts +33 -4
  45. package/src/types/models.ts +11 -0
  46. package/src/ui/analytics/ForgeAnalytics.tsx +134 -34
  47. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  48. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  49. package/src/ui/analytics/_tests/ForgeAnalytics.webVitals.spec.tsx +129 -0
  50. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  51. package/src/ui/analytics/useMetaEvents.ts +78 -0
  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/communityHarness.tsx +10 -3
  56. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  57. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  58. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  59. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  60. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  61. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  62. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  63. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  64. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  65. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  66. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  67. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  68. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  69. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  70. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  71. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  72. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  73. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  74. package/src/ui/headless/communities/index.ts +41 -0
  75. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  76. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  77. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  78. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  79. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  80. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  81. package/src/ui/headless/community/communityPoll.ts +297 -0
  82. package/src/ui/headless/community/countries.ts +123 -0
  83. package/src/ui/headless/community/index.ts +81 -1
  84. package/src/ui/headless/community/linkPreview.ts +71 -0
  85. package/src/ui/headless/community/richLexical.ts +27 -2
  86. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  87. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  88. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  89. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  90. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  91. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  92. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  93. package/src/ui/headless/downloads/index.ts +8 -0
  94. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  95. package/src/ui/headless/index.ts +13 -0
  96. package/src/ui/headless/memberHome/index.ts +10 -0
  97. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  98. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  99. package/src/ui/headless/portal/index.ts +57 -0
  100. package/src/ui/headless/portal/usePortal.ts +274 -0
  101. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  102. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  103. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  104. package/src/ui/headless/siteApp/appBar.ts +102 -0
  105. package/src/ui/headless/siteApp/appHome.ts +344 -0
  106. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  107. package/src/ui/headless/siteApp/index.ts +58 -0
  108. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  109. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  110. package/src/ui/headless/work/index.ts +2 -0
  111. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  112. package/src/ui/index.ts +11 -0
  113. package/src/ui/styled/AccountDashboard.tsx +712 -352
  114. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  115. package/src/ui/styled/ChatRoom.tsx +126 -16
  116. package/src/ui/styled/Checkout.tsx +8 -0
  117. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  118. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  119. package/src/ui/styled/CoachingDetail.tsx +2 -0
  120. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  121. package/src/ui/styled/CourseDetail.tsx +2 -0
  122. package/src/ui/styled/DownloadPage.tsx +263 -0
  123. package/src/ui/styled/EventConfirmation.tsx +10 -0
  124. package/src/ui/styled/EventDetail.tsx +10 -0
  125. package/src/ui/styled/FilmLibrary.tsx +22 -9
  126. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  127. package/src/ui/styled/ProductDetail.tsx +12 -0
  128. package/src/ui/styled/PwaRegistration.tsx +5 -1
  129. package/src/ui/styled/SignupForm.tsx +18 -3
  130. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  131. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  132. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  133. package/src/ui/styled/app/AppBar.tsx +298 -0
  134. package/src/ui/styled/app/AppHome.tsx +1139 -0
  135. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  136. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  137. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  138. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  139. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  140. package/src/ui/styled/app/index.ts +7 -0
  141. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  142. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  143. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  144. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  145. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  146. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  147. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  148. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  149. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  150. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  151. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  152. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  153. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  154. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  155. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  156. package/src/ui/styled/communities/index.ts +9 -0
  157. package/src/ui/styled/communities/shared.tsx +39 -11
  158. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  159. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  160. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  161. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  162. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  163. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  164. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  165. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  166. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  167. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  168. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  169. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  170. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  171. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  172. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  173. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  174. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  175. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  176. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  177. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  178. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  179. package/src/ui/styled/community/index.ts +37 -2
  180. package/src/ui/styled/forge-utilities.css +234 -0
  181. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  182. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  183. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  184. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  185. package/src/ui/styled/members/index.ts +8 -0
  186. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  187. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  188. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  189. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  190. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  191. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  192. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  193. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  194. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  195. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  196. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  197. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  198. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  199. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  200. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  201. package/src/ui/styled/portal/index.ts +13 -0
  202. package/src/ui/styled/portal/portalNav.ts +260 -0
  203. package/src/ui/styled/portal/shared.tsx +206 -0
  204. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  205. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  206. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  207. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  208. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  209. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  210. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  211. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  212. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  213. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  214. package/src/ui/styled/work/index.ts +1 -1
  215. package/src/utils/_tests/landing.spec.ts +39 -0
  216. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  217. package/src/utils/_tests/structuredDataSeo.spec.ts +247 -0
  218. package/src/utils/landing.ts +36 -10
  219. package/src/utils/metaEvents.ts +302 -0
  220. package/src/utils/structuredData.ts +339 -1
@@ -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,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
+ });
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { APP_BAR_HREFS, buildAppBar, isAppBarItemActive, normaliseBarItems } from "../siteApp/appBar";
3
+ import { SITE_APP_BAR_KEYS } from "../../../data/queries/useSiteApp";
4
+
5
+ /**
6
+ * The site app's bottom bar model: Home first, the creator's items (at most
7
+ * three) after it, More last, and every other destination behind More.
8
+ */
9
+
10
+ const keys = (items: Array<{ key: string }>) => items.map((item) => item.key);
11
+
12
+ describe("buildAppBar", () => {
13
+ it("puts Home first, then the configured items in their order, and the rest behind More", () => {
14
+ const model = buildAppBar({ barItems: ["shop", "events"], homeHref: "/i/app" });
15
+ expect(keys(model.bar)).toEqual(["home", "shop", "events"]);
16
+ // The sheet keeps the canonical order, not the configured one.
17
+ expect(keys(model.more)).toEqual(["communities", "portal", "inbox", "courses"]);
18
+ });
19
+
20
+ it("uses the contract's default bar when nothing is configured", () => {
21
+ for (const barItems of [undefined, null, []]) {
22
+ const model = buildAppBar({ barItems, homeHref: "/i/app" });
23
+ expect(keys(model.bar)).toEqual(["home", "communities", "portal", "inbox"]);
24
+ expect(keys(model.more)).toEqual(["events", "courses", "shop"]);
25
+ }
26
+ });
27
+
28
+ it("drops unknown keys and repeats and holds at most three items", () => {
29
+ const model = buildAppBar({
30
+ barItems: ["inbox", "nope", "inbox", "courses", "home", "shop", "events"],
31
+ homeHref: "/i/app",
32
+ });
33
+ expect(keys(model.bar)).toEqual(["home", "inbox", "courses", "shop"]);
34
+ expect(normaliseBarItems(["events", "events", "bogus"])).toEqual(["events"]);
35
+ });
36
+
37
+ it("lists every destination exactly once, in the bar or in More", () => {
38
+ for (const barItems of [["communities"], ["portal", "inbox", "events"], ["shop", "courses", "communities"]]) {
39
+ const model = buildAppBar({ barItems, homeHref: "/i/app" });
40
+ const all = [...keys(model.bar), ...keys(model.more)];
41
+ expect(all.sort()).toEqual(["home", ...SITE_APP_BAR_KEYS].sort());
42
+ }
43
+ });
44
+
45
+ it("links each destination to its page, and Home to the Home it is given", () => {
46
+ const model = buildAppBar({ barItems: ["inbox"], homeHref: "/members" });
47
+ expect(model.bar[0]).toMatchObject({ key: "home", href: "/members" });
48
+ expect(model.bar[1]).toMatchObject({ key: "inbox", href: "/i/account/notifications" });
49
+ expect(APP_BAR_HREFS).toEqual({
50
+ communities: "/i/communities",
51
+ portal: "/i/account",
52
+ inbox: "/i/account/notifications",
53
+ events: "/i/events",
54
+ courses: "/i/courses",
55
+ shop: "/i/store",
56
+ });
57
+ });
58
+
59
+ it("highlights the page being shown, and More when that page lives in the sheet", () => {
60
+ const onEvents = buildAppBar({ barItems: ["events"], homeHref: "/i/app", currentPath: "/i/events/summit" });
61
+ expect(onEvents.bar.find((item) => item.key === "events")?.active).toBe(true);
62
+ expect(onEvents.moreActive).toBe(false);
63
+
64
+ const inSheet = buildAppBar({ barItems: ["communities"], homeHref: "/i/app", currentPath: "/i/store/tee?x=1" });
65
+ expect(inSheet.more.find((item) => item.key === "shop")?.active).toBe(true);
66
+ expect(inSheet.moreActive).toBe(true);
67
+
68
+ const home = buildAppBar({ barItems: [], homeHref: "/i/app", currentPath: "/i/app/" });
69
+ expect(home.bar[0]!.active).toBe(true);
70
+ });
71
+
72
+ it("does not let the portal claim the inbox, which lives under it", () => {
73
+ expect(isAppBarItemActive("portal", "/i/account", "/i/account/notifications")).toBe(false);
74
+ expect(isAppBarItemActive("inbox", "/i/account/notifications", "/i/account/notifications")).toBe(true);
75
+ expect(isAppBarItemActive("portal", "/i/account", "/i/account/invoices")).toBe(true);
76
+ });
77
+
78
+ it("matches Home exactly, so a custom Home at the root does not light up on every page", () => {
79
+ expect(isAppBarItemActive("home", "/", "/i/events")).toBe(false);
80
+ expect(isAppBarItemActive("home", "/", "/")).toBe(true);
81
+ expect(isAppBarItemActive("events", "/i/events", "/i/eventsx")).toBe(false);
82
+ });
83
+ });
@@ -0,0 +1,19 @@
1
+ import type { SiteApp } from "../../../data/queries/useSiteApp";
2
+
3
+ /** The public site app settings (`GET /public/websites/app`), as the contract shapes them. */
4
+ export const siteApp = (patch: Partial<SiteApp> = {}): SiteApp => ({
5
+ websiteId: "website-1",
6
+ profileId: "profile-1",
7
+ appName: "Northlight",
8
+ shortName: "Northlight",
9
+ iconUrl: null,
10
+ themeColor: null,
11
+ backgroundColor: null,
12
+ startUrl: "/i/app",
13
+ homeSections: [{ type: "builtin", key: "portal", visible: true }],
14
+ barItems: ["communities", "portal", "inbox"],
15
+ installPrompt: true,
16
+ pushPrompt: true,
17
+ activeVersionId: null,
18
+ ...patch,
19
+ });