@tribe-nest/forge 3.60.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 (207) hide show
  1. package/package.json +2 -1
  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 +17 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/types/models.ts +11 -0
  37. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  38. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  39. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  40. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  41. package/src/ui/analytics/useMetaEvents.ts +78 -0
  42. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  43. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  44. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  45. package/src/ui/headless/_tests/communityHarness.tsx +10 -3
  46. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  47. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  48. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  49. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  50. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  51. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  52. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  53. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  54. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  55. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  56. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  57. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  58. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  59. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  60. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  61. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  62. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  63. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  64. package/src/ui/headless/communities/index.ts +41 -0
  65. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  66. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  67. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  68. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  69. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  70. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  71. package/src/ui/headless/community/communityPoll.ts +297 -0
  72. package/src/ui/headless/community/countries.ts +123 -0
  73. package/src/ui/headless/community/index.ts +81 -1
  74. package/src/ui/headless/community/linkPreview.ts +71 -0
  75. package/src/ui/headless/community/richLexical.ts +27 -2
  76. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  77. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  78. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  79. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  80. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  81. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  82. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  83. package/src/ui/headless/downloads/index.ts +8 -0
  84. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  85. package/src/ui/headless/index.ts +13 -0
  86. package/src/ui/headless/memberHome/index.ts +10 -0
  87. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  88. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  89. package/src/ui/headless/portal/index.ts +57 -0
  90. package/src/ui/headless/portal/usePortal.ts +274 -0
  91. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  92. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  93. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  94. package/src/ui/headless/siteApp/appBar.ts +102 -0
  95. package/src/ui/headless/siteApp/appHome.ts +344 -0
  96. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  97. package/src/ui/headless/siteApp/index.ts +58 -0
  98. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  99. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  100. package/src/ui/headless/work/index.ts +2 -0
  101. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  102. package/src/ui/index.ts +11 -0
  103. package/src/ui/styled/AccountDashboard.tsx +712 -352
  104. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  105. package/src/ui/styled/ChatRoom.tsx +126 -16
  106. package/src/ui/styled/Checkout.tsx +8 -0
  107. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  108. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  109. package/src/ui/styled/CoachingDetail.tsx +2 -0
  110. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  111. package/src/ui/styled/CourseDetail.tsx +2 -0
  112. package/src/ui/styled/DownloadPage.tsx +263 -0
  113. package/src/ui/styled/EventConfirmation.tsx +10 -0
  114. package/src/ui/styled/EventDetail.tsx +10 -0
  115. package/src/ui/styled/FilmLibrary.tsx +22 -9
  116. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  117. package/src/ui/styled/ProductDetail.tsx +12 -0
  118. package/src/ui/styled/PwaRegistration.tsx +5 -1
  119. package/src/ui/styled/SignupForm.tsx +18 -3
  120. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  121. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  122. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  123. package/src/ui/styled/app/AppBar.tsx +298 -0
  124. package/src/ui/styled/app/AppHome.tsx +1139 -0
  125. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  126. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  127. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  128. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  129. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  130. package/src/ui/styled/app/index.ts +7 -0
  131. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  132. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  133. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  134. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  135. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  136. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  137. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  138. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  139. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  140. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  141. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  142. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  143. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  144. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  145. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  146. package/src/ui/styled/communities/index.ts +9 -0
  147. package/src/ui/styled/communities/shared.tsx +39 -11
  148. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  149. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  150. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  151. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  152. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  153. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  154. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  155. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  156. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  157. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  158. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  159. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  160. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  161. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  162. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  163. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  164. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  165. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  166. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  167. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  168. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  169. package/src/ui/styled/community/index.ts +37 -2
  170. package/src/ui/styled/forge-utilities.css +234 -0
  171. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  172. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  173. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  174. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  175. package/src/ui/styled/members/index.ts +8 -0
  176. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  177. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  178. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  179. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  180. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  181. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  182. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  183. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  184. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  185. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  186. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  187. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  188. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  189. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  190. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  191. package/src/ui/styled/portal/index.ts +13 -0
  192. package/src/ui/styled/portal/portalNav.ts +260 -0
  193. package/src/ui/styled/portal/shared.tsx +206 -0
  194. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  195. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  196. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  197. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  198. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  199. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  200. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  201. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  202. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  203. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  204. package/src/ui/styled/work/index.ts +1 -1
  205. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  206. package/src/utils/landing.ts +19 -6
  207. package/src/utils/metaEvents.ts +302 -0
@@ -0,0 +1,167 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
4
+ import type { ReactNode } from "react";
5
+ import { ForgeThemeProvider } from "../../../theme/ForgeThemeProvider";
6
+ import { CommunityLinkPreview } from "../CommunityLinkPreview";
7
+ import { CommunityPostCard } from "../CommunityPostCard";
8
+ import type { CommunityLinkPreview as Preview, CommunityPost } from "../../../../data/queries/useCommunity";
9
+ import { linkPreviewDisplay, safeEmbedUrl, safeHttpUrl } from "../../../headless/community/linkPreview";
10
+ import { account, installAdapter, restoreAdapter, wrapper } from "../../../headless/_tests/communityHarness";
11
+
12
+ /**
13
+ * Link cards on posts: a plain link card that leaves the site without a
14
+ * referrer or an opener, a video that loads NO player until pressed, and a
15
+ * video from an origin we do not allow shown as a plain link card instead.
16
+ */
17
+
18
+ beforeEach(() => installAdapter([]));
19
+
20
+ afterEach(() => {
21
+ cleanup();
22
+ restoreAdapter();
23
+ });
24
+
25
+ const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
26
+
27
+ function mount(node: ReactNode) {
28
+ const Wrapper = wrapper({ user: account() });
29
+ return render(
30
+ <Wrapper>
31
+ <ForgeThemeProvider theme={theme}>{node}</ForgeThemeProvider>
32
+ </Wrapper>,
33
+ );
34
+ }
35
+
36
+ const linkCard = (overrides: Partial<Preview> = {}): Preview => ({
37
+ url: "https://news.example.com/story",
38
+ kind: "link",
39
+ provider: null,
40
+ siteName: "Example News",
41
+ title: "Shared article",
42
+ description: "Worth a read",
43
+ imageUrl: "https://news.example.com/og.jpg",
44
+ embedUrl: null,
45
+ ...overrides,
46
+ });
47
+
48
+ const video = (overrides: Partial<Preview> = {}): Preview => ({
49
+ url: "https://www.youtube.com/watch?v=abc123",
50
+ kind: "video",
51
+ provider: "youtube",
52
+ siteName: "YouTube",
53
+ title: "Studio session",
54
+ description: null,
55
+ imageUrl: "https://i.ytimg.com/vi/abc123/hqdefault.jpg",
56
+ embedUrl: "https://www.youtube-nocookie.com/embed/abc123",
57
+ ...overrides,
58
+ });
59
+
60
+ describe("link preview helpers", () => {
61
+ it("allows only http(s) links", () => {
62
+ expect(safeHttpUrl("https://example.com/a")).toBe("https://example.com/a");
63
+ expect(safeHttpUrl("javascript:alert(1)")).toBeNull();
64
+ expect(safeHttpUrl("data:text/html,hi")).toBeNull();
65
+ expect(safeHttpUrl("not a url")).toBeNull();
66
+ });
67
+
68
+ it("plays only from the allowed embed origins", () => {
69
+ expect(safeEmbedUrl(video())).toBe("https://www.youtube-nocookie.com/embed/abc123");
70
+ expect(safeEmbedUrl(video({ embedUrl: "https://player.vimeo.com/video/42" }))).toBe("https://player.vimeo.com/video/42");
71
+ expect(safeEmbedUrl(video({ embedUrl: "https://www.loom.com/embed/xyz" }))).toBe("https://www.loom.com/embed/xyz");
72
+ expect(safeEmbedUrl(video({ embedUrl: "https://www.youtube.com/embed/abc123" }))).toBeNull();
73
+ expect(safeEmbedUrl(video({ embedUrl: "https://evil.example/embed" }))).toBeNull();
74
+ expect(safeEmbedUrl(video({ embedUrl: "http://player.vimeo.com/video/42" }))).toBeNull();
75
+ expect(safeEmbedUrl(linkCard({ embedUrl: "https://player.vimeo.com/video/42" }))).toBeNull();
76
+ });
77
+
78
+ it("renders nothing for a preview with no safe link", () => {
79
+ expect(linkPreviewDisplay(linkCard({ url: "javascript:alert(1)" }))).toBeNull();
80
+ expect(linkPreviewDisplay(null)).toBeNull();
81
+ });
82
+ });
83
+
84
+ describe("CommunityLinkPreview", () => {
85
+ it("shows a link card that opens in a new tab with no referrer or opener", () => {
86
+ mount(<CommunityLinkPreview preview={linkCard()} />);
87
+ const card = screen.getByTestId("community-link-card");
88
+ expect(card.getAttribute("href")).toBe("https://news.example.com/story");
89
+ expect(card.getAttribute("target")).toBe("_blank");
90
+ expect(card.getAttribute("rel")).toBe("noopener noreferrer nofollow ugc");
91
+ expect(card.textContent).toContain("Example News");
92
+ expect(card.textContent).toContain("Shared article");
93
+ expect(card.textContent).toContain("Worth a read");
94
+ const image = card.querySelector("img")!;
95
+ expect(image.getAttribute("src")).toBe("https://news.example.com/og.jpg");
96
+ expect(image.getAttribute("referrerpolicy")).toBe("no-referrer");
97
+ expect(image.getAttribute("loading")).toBe("lazy");
98
+ });
99
+
100
+ it("does not load the video player until play is pressed", () => {
101
+ const { container } = mount(<CommunityLinkPreview preview={video()} />);
102
+ expect(container.querySelector("iframe")).toBeNull();
103
+ const thumbnail = container.querySelector("img")!;
104
+ expect(thumbnail.getAttribute("referrerpolicy")).toBe("no-referrer");
105
+ fireEvent.click(screen.getByRole("button", { name: "Play Studio session" }));
106
+ const frame = screen.getByTestId("community-link-video-frame");
107
+ expect(frame.getAttribute("src")).toBe("https://www.youtube-nocookie.com/embed/abc123?autoplay=1");
108
+ expect(frame.getAttribute("allow")).toBe("autoplay; fullscreen; picture-in-picture");
109
+ expect(frame.getAttribute("referrerpolicy")).toBe("strict-origin-when-cross-origin");
110
+ expect(frame.getAttribute("loading")).toBe("lazy");
111
+ expect(frame.getAttribute("title")).toBe("Studio session");
112
+ });
113
+
114
+ it("shows a video from an origin we do not allow as a link card", () => {
115
+ const { container } = mount(<CommunityLinkPreview preview={video({ embedUrl: "https://evil.example/embed/abc" })} />);
116
+ expect(screen.queryByTestId("community-link-video")).toBeNull();
117
+ expect(screen.getByTestId("community-link-card").getAttribute("href")).toBe("https://www.youtube.com/watch?v=abc123");
118
+ expect(container.querySelector("iframe")).toBeNull();
119
+ });
120
+
121
+ it("renders nothing while the post has no card yet", () => {
122
+ const { container } = mount(<CommunityLinkPreview preview={null} />);
123
+ expect(screen.queryByTestId("community-link-card")).toBeNull();
124
+ expect(screen.queryByTestId("community-link-video")).toBeNull();
125
+ expect(container.querySelector("a, iframe, img")).toBeNull();
126
+ });
127
+ });
128
+
129
+ describe("link cards on posts", () => {
130
+ const post = (overrides: Partial<CommunityPost> = {}): CommunityPost => ({
131
+ id: "post-1",
132
+ profileId: "profile-1",
133
+ spaceId: "space-1",
134
+ authorAccountId: "a-2",
135
+ title: "Look at this",
136
+ body: null,
137
+ bodyText: "Look at this https://news.example.com/story",
138
+ isPinned: false,
139
+ isLocked: false,
140
+ replyCount: 0,
141
+ reactionCount: 0,
142
+ lastActivityAt: null,
143
+ createdAt: "2026-09-01T00:00:00.000Z",
144
+ author: null,
145
+ media: [],
146
+ reactions: [],
147
+ linkPreview: linkCard(),
148
+ ...overrides,
149
+ });
150
+
151
+ it("shows the card under a feed post", () => {
152
+ mount(<CommunityPostCard post={post()} variant="feed" />);
153
+ expect(screen.getByTestId("community-link-card")).toBeTruthy();
154
+ expect(screen.queryByTestId("community-post-link-icon")).toBeNull();
155
+ });
156
+
157
+ it("shows only a small link mark on a compact forum row", () => {
158
+ mount(<CommunityPostCard post={post()} variant="forum" />);
159
+ expect(screen.queryByTestId("community-link-card")).toBeNull();
160
+ expect(screen.getByTestId("community-post-link-icon")).toBeTruthy();
161
+ });
162
+
163
+ it("shows no mark on a forum row without a card", () => {
164
+ mount(<CommunityPostCard post={post({ linkPreview: null })} variant="forum" />);
165
+ expect(screen.queryByTestId("community-post-link-icon")).toBeNull();
166
+ });
167
+ });
@@ -0,0 +1,137 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
4
+ import type { ReactNode } from "react";
5
+ import { ForgeThemeProvider } from "../../../theme/ForgeThemeProvider";
6
+ import { CommunityMembers } from "../CommunityMembers";
7
+ import { CommunityMemberProfile } from "../CommunityMemberProfile";
8
+ import {
9
+ account,
10
+ calls,
11
+ get,
12
+ installAdapter,
13
+ restoreAdapter,
14
+ wrapper,
15
+ } from "../../../headless/_tests/communityHarness";
16
+
17
+ /**
18
+ * The directory's markup side: member cards linking to profiles with where the
19
+ * member says they are, the members-only notice, the map's country list, and
20
+ * the member's own directory and map settings.
21
+ */
22
+
23
+ afterEach(() => {
24
+ cleanup();
25
+ restoreAdapter();
26
+ });
27
+
28
+ const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
29
+
30
+ function mount(node: ReactNode) {
31
+ const Wrapper = wrapper({ user: account() });
32
+ return render(
33
+ <Wrapper>
34
+ <ForgeThemeProvider theme={theme}>{node}</ForgeThemeProvider>
35
+ </Wrapper>,
36
+ );
37
+ }
38
+
39
+ const member = (overrides: Record<string, unknown> = {}) => ({
40
+ accountId: "a-2",
41
+ displayName: "Ada",
42
+ avatarUrl: null,
43
+ bio: null,
44
+ level: 3,
45
+ points: 120,
46
+ role: "moderator",
47
+ city: "Berlin",
48
+ countryCode: "DE",
49
+ lastActiveAt: null,
50
+ joinedAt: null,
51
+ firstName: null,
52
+ lastName: null,
53
+ ...overrides,
54
+ });
55
+
56
+ describe("CommunityMembers", () => {
57
+ it("shows member cards that open the profile", async () => {
58
+ installAdapter([
59
+ get("/public/community/members", () => ({
60
+ data: [member()],
61
+ total: 1,
62
+ page: 1,
63
+ pageSize: 24,
64
+ hasNextPage: false,
65
+ })),
66
+ ]);
67
+ const went: string[] = [];
68
+ mount(<CommunityMembers getProfileHref={(id) => `/profile/${id}`} navigate={(href) => went.push(href)} />);
69
+ const card = await screen.findByTestId("community-member-card");
70
+ expect(card.textContent).toContain("Ada");
71
+ expect(card.textContent).toContain("Moderator");
72
+ expect(card.textContent).toContain("Berlin, Germany");
73
+ fireEvent.click(within(card).getByRole("link"));
74
+ expect(went).toEqual(["/profile/a-2"]);
75
+ });
76
+
77
+ it("tells a non-member the directory is for members", async () => {
78
+ installAdapter([
79
+ get("/public/community/members", () => {
80
+ throw { status: 403, message: "Forbidden" };
81
+ }),
82
+ ]);
83
+ mount(<CommunityMembers getProfileHref={(id) => `/profile/${id}`} />);
84
+ expect(await screen.findByText("The member directory is for members of this community.")).toBeTruthy();
85
+ });
86
+
87
+ it("switches to the map and lists the countries with their counts", async () => {
88
+ installAdapter([
89
+ get("/public/community/members", () => ({ data: [], total: 0, page: 1, pageSize: 24, hasNextPage: false })),
90
+ get("/public/community/members/map", () => [
91
+ { countryCode: "NG", members: 4 },
92
+ { countryCode: "DE", members: 1 },
93
+ ]),
94
+ ]);
95
+ mount(<CommunityMembers getProfileHref={(id) => `/profile/${id}`} />);
96
+ fireEvent.click(await screen.findByRole("button", { name: "Map" }));
97
+ const rows = await screen.findAllByTestId("community-member-map-country");
98
+ expect(rows.map((row) => row.textContent)).toEqual(["Nigeria4", "Germany1"]);
99
+ expect(screen.getByText("5 members in 2 countries")).toBeTruthy();
100
+ });
101
+ });
102
+
103
+ describe("the member's own directory and map settings", () => {
104
+ it("saves the map switch and the country", async () => {
105
+ installAdapter([
106
+ get("/public/community/me", () => ({
107
+ id: "cp-1",
108
+ accountId: "account-1",
109
+ displayName: "Fan",
110
+ bio: null,
111
+ avatarUrl: null,
112
+ points: 0,
113
+ level: 1,
114
+ role: "member",
115
+ digestCadence: "daily",
116
+ showInDirectory: true,
117
+ showOnMap: false,
118
+ city: null,
119
+ countryCode: null,
120
+ canModerate: false,
121
+ })),
122
+ ]);
123
+ mount(<CommunityMemberProfile accountId="account-1" />);
124
+ const settings = await screen.findByTestId("community-profile-directory-settings");
125
+ const directory = within(settings).getByRole("switch", { name: "Show me in the member directory" });
126
+ expect(directory.getAttribute("aria-checked")).toBe("true");
127
+
128
+ fireEvent.change(within(settings).getByRole("combobox"), { target: { value: "NG" } });
129
+ fireEvent.click(within(settings).getByRole("switch", { name: "Show me on the member map" }));
130
+ await waitFor(() => expect(calls("put", "/public/community/me")).toHaveLength(1));
131
+ expect(calls("put", "/public/community/me")[0].body).toMatchObject({
132
+ showOnMap: true,
133
+ countryCode: "NG",
134
+ city: null,
135
+ });
136
+ });
137
+ });
@@ -0,0 +1,291 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
4
+ import type { ReactNode } from "react";
5
+ import { ForgeThemeProvider } from "../../../theme/ForgeThemeProvider";
6
+ import { CommunityModeration } from "../CommunityModeration";
7
+ import { CommunityNotificationsInbox } from "../CommunityNotifications";
8
+ import { CommunityPostCard } from "../CommunityPostCard";
9
+ import type { CommunityPost } from "../../../../data/queries/useCommunity";
10
+ import {
11
+ PROFILE_ID,
12
+ account,
13
+ calls,
14
+ get,
15
+ installAdapter,
16
+ post,
17
+ restoreAdapter,
18
+ wrapper,
19
+ } from "../../../headless/_tests/communityHarness";
20
+
21
+ /**
22
+ * The site's moderation surfaces are their headless hooks plus markup. These
23
+ * pin the markup's side: the Moderation tab puts hold reasons into words and
24
+ * asks for a reason on reject; the post card shows the author their held post
25
+ * without reply or react, and gives a moderator the actions menu (with no ban);
26
+ * the inbox words a moderation outcome and leaves the mute notice as it was.
27
+ */
28
+
29
+ afterEach(() => {
30
+ cleanup();
31
+ restoreAdapter();
32
+ });
33
+
34
+ const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
35
+
36
+ function mount(node: ReactNode, opts: Parameters<typeof wrapper>[0] = { user: account() }) {
37
+ const Wrapper = wrapper(opts);
38
+ return render(
39
+ <Wrapper>
40
+ <ForgeThemeProvider theme={theme}>{node}</ForgeThemeProvider>
41
+ </Wrapper>,
42
+ );
43
+ }
44
+
45
+ const me = (canModerate: boolean) => get("/public/community/me", () => ({ accountId: "account-1", canModerate }));
46
+ const emptyLog = get("/public/community-moderation/log", () => ({
47
+ data: [],
48
+ total: 0,
49
+ page: 1,
50
+ pageSize: 20,
51
+ hasNextPage: false,
52
+ }));
53
+
54
+ const queue = {
55
+ reports: {
56
+ data: [
57
+ {
58
+ id: "rep-1",
59
+ entityType: "community_post",
60
+ entityId: "post-9",
61
+ reason: "spam",
62
+ reporterFirstName: "Ada",
63
+ reporterLastName: null,
64
+ contentPreview: "buy now",
65
+ contentHidden: false,
66
+ createdAt: "2026-09-01T00:00:00.000Z",
67
+ },
68
+ ],
69
+ total: 1,
70
+ },
71
+ pending: {
72
+ posts: [
73
+ {
74
+ id: "post-1",
75
+ spaceId: "space-1",
76
+ spaceName: "General",
77
+ title: "Hello",
78
+ bodyText: "first post",
79
+ authorAccountId: "account-2",
80
+ authorName: "New Fan",
81
+ moderationReason: "approval_new_member",
82
+ createdAt: "2026-09-01T00:00:00.000Z",
83
+ },
84
+ {
85
+ id: "post-2",
86
+ spaceId: "space-1",
87
+ spaceName: "General",
88
+ title: null,
89
+ bodyText: "spoilers ahead",
90
+ authorAccountId: "account-3",
91
+ authorName: "Other Fan",
92
+ moderationReason: "keyword:spoiler",
93
+ createdAt: "2026-09-01T00:00:00.000Z",
94
+ },
95
+ ],
96
+ replies: [],
97
+ },
98
+ };
99
+
100
+ const postFixture = (overrides: Partial<CommunityPost> = {}): CommunityPost => ({
101
+ id: "post-1",
102
+ profileId: PROFILE_ID,
103
+ spaceId: "space-1",
104
+ authorAccountId: "account-2",
105
+ title: "Hello",
106
+ body: { root: { type: "root", children: [{ type: "paragraph", children: [{ type: "text", text: "hi all" }] }] } },
107
+ bodyText: "hi all",
108
+ isPinned: false,
109
+ isLocked: false,
110
+ replyCount: 0,
111
+ reactionCount: 0,
112
+ lastActivityAt: null,
113
+ createdAt: "2026-09-01T00:00:00.000Z",
114
+ author: {
115
+ accountId: "account-2",
116
+ firstName: "Fan",
117
+ lastName: null,
118
+ displayName: "Fan",
119
+ avatarUrl: null,
120
+ level: 1,
121
+ role: "member",
122
+ },
123
+ media: [],
124
+ reactions: [],
125
+ ...overrides,
126
+ });
127
+
128
+ describe("CommunityModeration (the tab)", () => {
129
+ it("tells a non-moderator this page is not for them, and loads nothing", async () => {
130
+ installAdapter([me(false)]);
131
+ mount(<CommunityModeration />);
132
+ expect(await screen.findByText("This page is for the community's moderators.")).toBeTruthy();
133
+ expect(calls("get", "/public/community-moderation/queue")).toHaveLength(0);
134
+ });
135
+
136
+ it("lists reports and held posts, with the hold reason in words", async () => {
137
+ installAdapter([me(true), get("/public/community-moderation/queue", () => queue), emptyLog]);
138
+ mount(<CommunityModeration />);
139
+ const report = await screen.findByTestId("community-moderation-report");
140
+ expect(report.textContent).toContain("buy now");
141
+ expect(report.textContent).toContain("Reported by Ada");
142
+ expect(report.textContent).toContain("Reason: spam");
143
+
144
+ const reasons = (await screen.findAllByTestId("community-moderation-hold-reason")).map((el) => el.textContent);
145
+ expect(reasons).toEqual(["New member", "Blocked word: spoiler"]);
146
+ expect(await screen.findByText("No moderation actions yet.")).toBeTruthy();
147
+ });
148
+
149
+ it("asks for an optional reason before rejecting", async () => {
150
+ installAdapter([
151
+ me(true),
152
+ get("/public/community-moderation/queue", () => queue),
153
+ emptyLog,
154
+ post("/public/community-moderation/posts/post-1/reject", () => ({ success: true })),
155
+ ]);
156
+ mount(<CommunityModeration />);
157
+ const [first] = await screen.findAllByTestId("community-moderation-held-item");
158
+ fireEvent.click(within(first).getByRole("button", { name: "Reject" }));
159
+ fireEvent.change(within(first).getByRole("textbox"), { target: { value: "Please keep it on topic" } });
160
+ fireEvent.click(within(first).getByRole("button", { name: "Reject" }));
161
+ await waitFor(() => expect(calls("post", "/public/community-moderation/posts/post-1/reject")).toHaveLength(1));
162
+ expect(calls("post", "/public/community-moderation/posts/post-1/reject")[0].body).toEqual({
163
+ profileId: PROFILE_ID,
164
+ reason: "Please keep it on topic",
165
+ });
166
+ });
167
+
168
+ it("opens a reported reply's post, and not a hidden reported post", async () => {
169
+ const report = queue.reports.data[0];
170
+ const reports = [
171
+ { ...report, id: "rep-reply", entityType: "community_reply", entityId: "reply-7", postId: "post-7" },
172
+ { ...report, id: "rep-hidden", entityId: "post-8", postId: "post-8", contentHidden: true },
173
+ ];
174
+ installAdapter([
175
+ me(true),
176
+ get("/public/community-moderation/queue", () => ({ ...queue, reports: { data: reports, total: 2 } })),
177
+ emptyLog,
178
+ ]);
179
+ const opened: string[] = [];
180
+ mount(<CommunityModeration onOpenPost={(postId) => opened.push(postId)} />);
181
+ const [replyReport, hiddenReport] = await screen.findAllByTestId("community-moderation-report");
182
+ fireEvent.click(within(replyReport).getByRole("button", { name: "Open post" }));
183
+ expect(opened).toEqual(["post-7"]);
184
+ expect(within(hiddenReport).queryByRole("button", { name: "Open post" })).toBeNull();
185
+ });
186
+ });
187
+
188
+ describe("CommunityPostCard moderation states", () => {
189
+ it("shows the author their held post, with no way to reply or react", async () => {
190
+ installAdapter([me(false)]);
191
+ mount(
192
+ <CommunityPostCard post={postFixture({ status: "pending", moderationReason: "approval_all" })} variant="feed" />,
193
+ );
194
+ expect((await screen.findByTestId("community-pending-badge")).textContent).toContain("Waiting for approval");
195
+ expect(screen.queryByRole("button", { name: /reply/i })).toBeNull();
196
+ });
197
+
198
+ it("gives a moderator the actions menu, and it has no ban", async () => {
199
+ installAdapter([
200
+ me(true),
201
+ post("/public/community-moderation/posts/post-1/lock", () => ({ id: "post-1", isLocked: true })),
202
+ ]);
203
+ mount(<CommunityPostCard post={postFixture()} variant="feed" />);
204
+ fireEvent.click(await screen.findByRole("button", { name: "Moderator actions" }));
205
+ const menu = screen.getByRole("menu");
206
+ const labels = within(menu)
207
+ .getAllByRole("menuitem")
208
+ .map((el) => el.textContent);
209
+ expect(labels).toEqual(["Pin", "Lock replies", "Remove post", "Mute author"]);
210
+ expect(menu.textContent?.toLowerCase()).not.toContain("ban");
211
+
212
+ fireEvent.click(within(menu).getByRole("menuitem", { name: "Mute author" }));
213
+ expect(
214
+ within(screen.getByRole("menu"))
215
+ .getAllByRole("menuitem")
216
+ .map((el) => el.textContent),
217
+ ).toEqual(["1 day", "7 days", "30 days"]);
218
+ });
219
+
220
+ it("shows no menu to a member who does not moderate", async () => {
221
+ installAdapter([me(false)]);
222
+ mount(<CommunityPostCard post={postFixture()} variant="feed" />);
223
+ await waitFor(() => expect(calls("get", "/public/community/me")).toHaveLength(1));
224
+ expect(screen.queryByRole("button", { name: "Moderator actions" })).toBeNull();
225
+ });
226
+
227
+ it("leaves mute off a moderator's own post", async () => {
228
+ installAdapter([me(true)]);
229
+ mount(<CommunityPostCard post={postFixture({ authorAccountId: "account-1" })} variant="forum" />);
230
+ fireEvent.click(await screen.findByRole("button", { name: "Moderator actions" }));
231
+ const labels = within(screen.getByRole("menu"))
232
+ .getAllByRole("menuitem")
233
+ .map((el) => el.textContent);
234
+ expect(labels).not.toContain("Mute author");
235
+ });
236
+
237
+ it("collapses a just-removed post to an undo", async () => {
238
+ installAdapter([me(true), post("/public/community-moderation/posts/post-1/restore", () => ({ success: true }))]);
239
+ mount(<CommunityPostCard post={postFixture({ archivedAt: "2026-09-29T00:00:00.000Z" })} variant="feed" />);
240
+ const notice = await screen.findByTestId("community-removed-notice");
241
+ expect(notice.textContent).toContain("Post removed");
242
+ fireEvent.click(await within(notice).findByRole("button", { name: "Restore" }));
243
+ await waitFor(() => expect(calls("post", "/public/community-moderation/posts/post-1/restore")).toHaveLength(1));
244
+ });
245
+ });
246
+
247
+ describe("CommunityNotificationsInbox moderation outcomes", () => {
248
+ it("words the outcome, and keeps the mute notice as it was", async () => {
249
+ installAdapter([
250
+ get("/public/community/notifications", () => ({
251
+ unreadCount: 0,
252
+ data: [
253
+ {
254
+ id: "n1",
255
+ type: "mod",
256
+ entityType: "community_post",
257
+ entityId: "post-1",
258
+ data: { outcome: "rejected", reason: "off topic" },
259
+ readAt: "2026-09-01T00:00:00.000Z",
260
+ createdAt: "2026-09-01T00:00:00.000Z",
261
+ },
262
+ {
263
+ id: "n2",
264
+ type: "mod",
265
+ entityType: "community_reply",
266
+ entityId: "reply-1",
267
+ data: { outcome: "approved", reason: null },
268
+ readAt: "2026-09-01T00:00:00.000Z",
269
+ createdAt: "2026-09-01T00:00:00.000Z",
270
+ },
271
+ {
272
+ id: "n3",
273
+ type: "mod",
274
+ entityType: "community",
275
+ entityId: "c-1",
276
+ data: null,
277
+ readAt: "2026-09-01T00:00:00.000Z",
278
+ createdAt: "2026-09-01T00:00:00.000Z",
279
+ },
280
+ ],
281
+ })),
282
+ ]);
283
+ mount(<CommunityNotificationsInbox onOpenPost={() => {}} />);
284
+ await waitFor(() => expect(screen.getAllByTestId("community-notification-title")).toHaveLength(3));
285
+ expect(screen.getAllByTestId("community-notification-title").map((el) => el.textContent)).toEqual([
286
+ "Your post wasn't approved: off topic",
287
+ "Your reply was approved",
288
+ "A moderator updated your account",
289
+ ]);
290
+ });
291
+ });