@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,127 @@
1
+ import { Clock, EyeOff } from "lucide-react";
2
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
3
+ import { useForgeT } from "../../../i18n";
4
+ import { useCommunityModeratorActions } from "../../headless/community/useCommunityModeration";
5
+ import type { CommunityPost, CommunityReply } from "../../../data/queries/useCommunity";
6
+ import { CallHelpHint } from "../../media/CallHelpHint";
7
+
8
+ export interface CommunityPendingBadgeProps {
9
+ /** "rejected" shows "Not approved" instead of "Waiting for approval". */
10
+ status?: "pending" | "rejected";
11
+ className?: string;
12
+ }
13
+
14
+ /**
15
+ * Shown to an author on their own post or reply while it waits for a
16
+ * moderator. The state is visible; what it means sits behind the `?`.
17
+ */
18
+ export function CommunityPendingBadge({ status = "pending", className }: CommunityPendingBadgeProps) {
19
+ const t = useThemeTokens();
20
+ const tr = useForgeT();
21
+ return (
22
+ <span
23
+ className={className}
24
+ data-testid="community-pending-badge"
25
+ style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
26
+ >
27
+ <span
28
+ style={{
29
+ display: "inline-flex",
30
+ alignItems: "center",
31
+ gap: 4,
32
+ padding: "2px 8px",
33
+ borderRadius: 999,
34
+ background: `${t.primary}1a`,
35
+ color: t.primary,
36
+ fontSize: 11,
37
+ fontWeight: 700,
38
+ whiteSpace: "nowrap",
39
+ }}
40
+ >
41
+ <Clock size={11} aria-hidden />
42
+ {tr(
43
+ status === "rejected"
44
+ ? "forge.community_moderation_state.rejected"
45
+ : "forge.community_moderation_state.pending",
46
+ )}
47
+ </span>
48
+ {status === "pending" && (
49
+ <CallHelpHint label={tr("forge.community_moderation_state.pending_help_label")}>
50
+ {tr("forge.community_moderation_state.pending_help")}
51
+ </CallHelpHint>
52
+ )}
53
+ </span>
54
+ );
55
+ }
56
+
57
+ export type CommunityRemovedNoticeTarget =
58
+ | { type: "post"; post: Pick<CommunityPost, "id"> }
59
+ | { type: "reply"; reply: Pick<CommunityReply, "id" | "postId"> };
60
+
61
+ export interface CommunityRemovedNoticeProps {
62
+ target: CommunityRemovedNoticeTarget;
63
+ className?: string;
64
+ }
65
+
66
+ /**
67
+ * What a moderator sees in place of a post or reply they just removed, with
68
+ * the undo right there. After a reload the content is gone from the feed and
69
+ * the moderation log is the way back.
70
+ */
71
+ export function CommunityRemovedNotice({ target, className }: CommunityRemovedNoticeProps) {
72
+ const t = useThemeTokens();
73
+ const tr = useForgeT();
74
+ const mod = useCommunityModeratorActions();
75
+ const isPost = target.type === "post";
76
+
77
+ return (
78
+ <div
79
+ className={className}
80
+ data-testid="community-removed-notice"
81
+ role="status"
82
+ style={{
83
+ display: "flex",
84
+ alignItems: "center",
85
+ gap: 10,
86
+ flexWrap: "wrap",
87
+ border: `1px dashed ${t.border}`,
88
+ borderRadius: t.cornerRadius,
89
+ padding: "10px 14px",
90
+ color: t.muted,
91
+ fontSize: 13,
92
+ }}
93
+ >
94
+ <EyeOff size={14} aria-hidden />
95
+ <span style={{ flex: 1, minWidth: 0 }}>
96
+ {tr(
97
+ isPost ? "forge.community_moderation_state.removed_post" : "forge.community_moderation_state.removed_reply",
98
+ )}
99
+ </span>
100
+ {mod.canModerate && (
101
+ <button
102
+ type="button"
103
+ disabled={mod.isPending}
104
+ onClick={() => (isPost ? mod.restorePost(target.post) : mod.restoreReply(target.reply))}
105
+ style={{
106
+ background: "transparent",
107
+ border: `1px solid ${t.border}`,
108
+ borderRadius: t.cornerRadius,
109
+ padding: "4px 12px",
110
+ cursor: mod.isPending ? "default" : "pointer",
111
+ color: t.primary,
112
+ fontWeight: 600,
113
+ fontSize: 12,
114
+ opacity: mod.isPending ? 0.6 : 1,
115
+ }}
116
+ >
117
+ {tr("forge.community_moderation_state.restore")}
118
+ </button>
119
+ )}
120
+ {mod.failed && (
121
+ <span role="alert" style={{ width: "100%", color: t.text, fontWeight: 600, fontSize: 12 }}>
122
+ {mod.error ?? tr("forge.community_moderation_state.error")}
123
+ </span>
124
+ )}
125
+ </div>
126
+ );
127
+ }
@@ -0,0 +1,317 @@
1
+ import { useEffect, useRef, useState, type CSSProperties } from "react";
2
+ import { Lock, MoreHorizontal, Pin, PinOff, Trash2, Unlock, VolumeX } from "lucide-react";
3
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
4
+ import { useForgeT } from "../../../i18n";
5
+ import {
6
+ COMMUNITY_MUTE_DAYS,
7
+ useCommunityModeratorActions,
8
+ type CommunityMuteDays,
9
+ } from "../../headless/community/useCommunityModeration";
10
+ import type { CommunityPost, CommunityReply } from "../../../data/queries/useCommunity";
11
+ import { CallHelpHint } from "../../media/CallHelpHint";
12
+
13
+ export type CommunityModeratorMenuTarget =
14
+ | { type: "post"; post: CommunityPost }
15
+ | { type: "reply"; reply: CommunityReply };
16
+
17
+ export interface CommunityModeratorMenuProps {
18
+ target: CommunityModeratorMenuTarget;
19
+ /** Which edge of the trigger the menu lines up with. Default right. */
20
+ align?: "left" | "right";
21
+ className?: string;
22
+ }
23
+
24
+ const MUTE_LABELS: Record<CommunityMuteDays, string> = {
25
+ 1: "forge.community_moderator_menu.mute_1",
26
+ 7: "forge.community_moderator_menu.mute_7",
27
+ 30: "forge.community_moderator_menu.mute_30",
28
+ };
29
+
30
+ /**
31
+ * The moderator's "..." on a post or reply: pin and lock (posts), remove with
32
+ * an optional reason, and mute the author for a day, a week or a month. Renders
33
+ * nothing for anyone who does not moderate this community. There is no ban
34
+ * here: only an admin bans, from the dashboard. Built on
35
+ * `useCommunityModeratorActions`.
36
+ */
37
+ export function CommunityModeratorMenu({ target, align = "right", className }: CommunityModeratorMenuProps) {
38
+ const t = useThemeTokens();
39
+ const tr = useForgeT();
40
+ const mod = useCommunityModeratorActions();
41
+ const [open, setOpen] = useState(false);
42
+ const [view, setView] = useState<"menu" | "remove" | "mute">("menu");
43
+ const [reason, setReason] = useState("");
44
+ const [notice, setNotice] = useState<string | null>(null);
45
+ const ref = useRef<HTMLDivElement>(null);
46
+
47
+ useEffect(() => {
48
+ if (!open) return;
49
+ const onDoc = (e: MouseEvent) => {
50
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
51
+ };
52
+ const onKey = (e: KeyboardEvent) => {
53
+ if (e.key === "Escape") setOpen(false);
54
+ };
55
+ document.addEventListener("mousedown", onDoc);
56
+ document.addEventListener("keydown", onKey);
57
+ return () => {
58
+ document.removeEventListener("mousedown", onDoc);
59
+ document.removeEventListener("keydown", onKey);
60
+ };
61
+ }, [open]);
62
+
63
+ if (!mod.canModerate) return null;
64
+
65
+ const isPost = target.type === "post";
66
+ const authorId = isPost ? target.post.authorAccountId : target.reply.authorAccountId;
67
+ const canMute = !!authorId && authorId !== mod.currentAccountId;
68
+
69
+ const toggle = () => {
70
+ setOpen((was) => !was);
71
+ setView("menu");
72
+ setReason("");
73
+ setNotice(null);
74
+ mod.clearError();
75
+ };
76
+
77
+ const finish = async (ok: Promise<boolean>, doneNotice?: string) => {
78
+ if (await ok) {
79
+ setOpen(false);
80
+ setView("menu");
81
+ setReason("");
82
+ if (doneNotice) setNotice(doneNotice);
83
+ }
84
+ };
85
+
86
+ const item: CSSProperties = {
87
+ display: "flex",
88
+ alignItems: "center",
89
+ gap: 8,
90
+ width: "100%",
91
+ padding: "8px 10px",
92
+ background: "transparent",
93
+ border: "none",
94
+ borderRadius: t.cornerRadius,
95
+ cursor: mod.isPending ? "default" : "pointer",
96
+ color: t.text,
97
+ fontSize: 13,
98
+ textAlign: "left",
99
+ fontFamily: "inherit",
100
+ opacity: mod.isPending ? 0.6 : 1,
101
+ };
102
+ const smallButton: CSSProperties = {
103
+ background: "transparent",
104
+ border: `1px solid ${t.border}`,
105
+ borderRadius: t.cornerRadius,
106
+ padding: "5px 10px",
107
+ cursor: "pointer",
108
+ color: t.text,
109
+ fontSize: 12,
110
+ fontFamily: "inherit",
111
+ };
112
+ const primaryButton: CSSProperties = {
113
+ ...smallButton,
114
+ background: t.primary,
115
+ color: t.textPrimary,
116
+ border: "none",
117
+ fontWeight: 600,
118
+ opacity: mod.isPending ? 0.6 : 1,
119
+ };
120
+
121
+ return (
122
+ <div
123
+ ref={ref}
124
+ className={className}
125
+ data-testid="community-moderator-menu"
126
+ style={{ position: "relative", display: "inline-flex", alignItems: "center", gap: 6 }}
127
+ >
128
+ {notice && (
129
+ <span role="status" style={{ fontSize: 11, color: t.muted }}>
130
+ {notice}
131
+ </span>
132
+ )}
133
+ <button
134
+ type="button"
135
+ onClick={toggle}
136
+ aria-label={tr("forge.community_moderator_menu.aria_trigger")}
137
+ aria-haspopup="menu"
138
+ aria-expanded={open}
139
+ style={{
140
+ display: "inline-flex",
141
+ alignItems: "center",
142
+ justifyContent: "center",
143
+ width: 28,
144
+ height: 28,
145
+ background: open ? `${t.primary}1a` : "transparent",
146
+ border: "none",
147
+ borderRadius: 999,
148
+ cursor: "pointer",
149
+ color: open ? t.primary : t.muted,
150
+ padding: 0,
151
+ }}
152
+ >
153
+ <MoreHorizontal size={16} />
154
+ </button>
155
+
156
+ {open && (
157
+ <div
158
+ role="menu"
159
+ style={{
160
+ position: "absolute",
161
+ top: "calc(100% + 4px)",
162
+ ...(align === "left" ? { left: 0 } : { right: 0 }),
163
+ minWidth: 200,
164
+ maxWidth: "calc(100vw - 32px)",
165
+ background: t.surface,
166
+ color: t.text,
167
+ border: `1px solid ${t.border}`,
168
+ borderRadius: t.cornerRadius,
169
+ boxShadow: "0 10px 30px -12px rgba(0,0,0,0.4)",
170
+ padding: 6,
171
+ zIndex: 40,
172
+ fontFamily: t.fontFamily,
173
+ }}
174
+ >
175
+ {view === "menu" && (
176
+ <>
177
+ {isPost && (
178
+ <button
179
+ type="button"
180
+ role="menuitem"
181
+ disabled={mod.isPending}
182
+ style={item}
183
+ onClick={() => finish(target.post.isPinned ? mod.unpinPost(target.post) : mod.pinPost(target.post))}
184
+ >
185
+ {target.post.isPinned ? <PinOff size={14} /> : <Pin size={14} />}
186
+ {tr(
187
+ target.post.isPinned
188
+ ? "forge.community_moderator_menu.unpin"
189
+ : "forge.community_moderator_menu.pin",
190
+ )}
191
+ </button>
192
+ )}
193
+ {isPost && (
194
+ <button
195
+ type="button"
196
+ role="menuitem"
197
+ disabled={mod.isPending}
198
+ style={item}
199
+ onClick={() => finish(target.post.isLocked ? mod.unlockPost(target.post) : mod.lockPost(target.post))}
200
+ >
201
+ {target.post.isLocked ? <Unlock size={14} /> : <Lock size={14} />}
202
+ {tr(
203
+ target.post.isLocked
204
+ ? "forge.community_moderator_menu.unlock"
205
+ : "forge.community_moderator_menu.lock",
206
+ )}
207
+ </button>
208
+ )}
209
+ <button type="button" role="menuitem" style={item} onClick={() => setView("remove")}>
210
+ <Trash2 size={14} />
211
+ {tr(
212
+ isPost ? "forge.community_moderator_menu.remove_post" : "forge.community_moderator_menu.remove_reply",
213
+ )}
214
+ </button>
215
+ {canMute && (
216
+ <button type="button" role="menuitem" style={item} onClick={() => setView("mute")}>
217
+ <VolumeX size={14} />
218
+ {tr("forge.community_moderator_menu.mute_author")}
219
+ </button>
220
+ )}
221
+ </>
222
+ )}
223
+
224
+ {view === "remove" && (
225
+ <form
226
+ style={{ display: "flex", flexDirection: "column", gap: 8, padding: 6 }}
227
+ onSubmit={(e) => {
228
+ e.preventDefault();
229
+ if (mod.isPending) return;
230
+ finish(isPost ? mod.removePost(target.post, reason) : mod.removeReply(target.reply, reason));
231
+ }}
232
+ >
233
+ <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 12, fontWeight: 600 }}>
234
+ {tr("forge.community_moderator_menu.remove_reason_label")}
235
+ <CallHelpHint label={tr("forge.community_moderator_menu.remove_reason_help_label")}>
236
+ {tr("forge.community_moderator_menu.remove_reason_help")}
237
+ </CallHelpHint>
238
+ </label>
239
+ <textarea
240
+ value={reason}
241
+ onChange={(e) => setReason(e.target.value)}
242
+ maxLength={500}
243
+ rows={2}
244
+ autoFocus
245
+ style={{
246
+ width: "100%",
247
+ boxSizing: "border-box",
248
+ padding: "6px 8px",
249
+ borderRadius: t.cornerRadius,
250
+ border: `1px solid ${t.border}`,
251
+ background: t.background,
252
+ color: t.text,
253
+ fontFamily: "inherit",
254
+ fontSize: 13,
255
+ resize: "vertical",
256
+ }}
257
+ />
258
+ <div style={{ display: "flex", justifyContent: "flex-end", gap: 6 }}>
259
+ <button type="button" style={smallButton} onClick={() => setView("menu")}>
260
+ {tr("forge.community_moderator_menu.back")}
261
+ </button>
262
+ <button type="submit" style={primaryButton} disabled={mod.isPending}>
263
+ {tr("forge.community_moderator_menu.remove_confirm")}
264
+ </button>
265
+ </div>
266
+ </form>
267
+ )}
268
+
269
+ {view === "mute" && (
270
+ <div style={{ display: "flex", flexDirection: "column", gap: 4, padding: 4 }}>
271
+ <div
272
+ style={{
273
+ display: "flex",
274
+ alignItems: "center",
275
+ gap: 6,
276
+ fontSize: 12,
277
+ fontWeight: 600,
278
+ padding: "2px 6px",
279
+ }}
280
+ >
281
+ {tr("forge.community_moderator_menu.mute_for")}
282
+ <CallHelpHint label={tr("forge.community_moderator_menu.mute_help_label")}>
283
+ {tr("forge.community_moderator_menu.mute_help")}
284
+ </CallHelpHint>
285
+ </div>
286
+ {COMMUNITY_MUTE_DAYS.map((days) => (
287
+ <button
288
+ key={days}
289
+ type="button"
290
+ role="menuitem"
291
+ disabled={mod.isPending}
292
+ style={item}
293
+ onClick={() => authorId && finish(mod.muteMember(authorId, days), tr("forge.community_moderator_menu.muted"))}
294
+ >
295
+ {tr(MUTE_LABELS[days])}
296
+ </button>
297
+ ))}
298
+ <button
299
+ type="button"
300
+ style={{ ...smallButton, alignSelf: "flex-end", marginTop: 4 }}
301
+ onClick={() => setView("menu")}
302
+ >
303
+ {tr("forge.community_moderator_menu.back")}
304
+ </button>
305
+ </div>
306
+ )}
307
+
308
+ {mod.failed && (
309
+ <p role="alert" style={{ margin: "6px 6px 2px", fontSize: 12, color: t.text, fontWeight: 600 }}>
310
+ {mod.error ?? tr("forge.community_moderator_menu.error")}
311
+ </p>
312
+ )}
313
+ </div>
314
+ )}
315
+ </div>
316
+ );
317
+ }
@@ -1,4 +1,4 @@
1
- import { AtSign, Bell, Heart, MessageCircle, ShieldAlert, Sparkles } from "lucide-react";
1
+ import { Award, AtSign, Bell, Heart, MessageCircle, ShieldAlert, Sparkles } from "lucide-react";
2
2
  import { useThemeTokens } from "../../theme/ForgeThemeProvider";
3
3
  import {
4
4
  useCommunityNotifications,
@@ -6,9 +6,34 @@ import {
6
6
  useMarkCommunityNotificationsRead,
7
7
  type CommunityNotificationItem,
8
8
  } from "../../../data/queries/useCommunity";
9
+ import { useForgeT } from "../../../i18n";
10
+ import {
11
+ describeModerationNotification,
12
+ type CommunityModerationOutcome,
13
+ } from "../../headless/community/useCommunityModeration";
9
14
  import { Loading } from "../Loading";
10
15
  import { timeAgo } from "./util";
11
16
 
17
+ /** The level a `level_up` notice announces, or null when it carries none we can read. */
18
+ export function levelUpLevel(n: Pick<CommunityNotificationItem, "type" | "data">): number | null {
19
+ if (n.type !== "level_up") return null;
20
+ const level = Number((n.data as { level?: unknown } | null | undefined)?.level);
21
+ return Number.isInteger(level) && level > 0 ? level : null;
22
+ }
23
+
24
+ /** "Your post was approved" / "Your reply wasn't approved: <reason>". */
25
+ function moderationOutcomeKey(o: CommunityModerationOutcome): string {
26
+ if (o.outcome === "approved") {
27
+ return o.subject === "post" ? "forge.community_notifications.post_approved" : "forge.community_notifications.reply_approved";
28
+ }
29
+ if (o.reason) {
30
+ return o.subject === "post"
31
+ ? "forge.community_notifications.post_rejected_reason"
32
+ : "forge.community_notifications.reply_rejected_reason";
33
+ }
34
+ return o.subject === "post" ? "forge.community_notifications.post_rejected" : "forge.community_notifications.reply_rejected";
35
+ }
36
+
12
37
  // ── Bell ─────────────────────────────────────────────────────────────────────
13
38
 
14
39
  export interface CommunityNotificationsBellProps {
@@ -90,6 +115,8 @@ function NotificationIcon({ type }: { type: string }) {
90
115
  return <Sparkles {...props} />;
91
116
  case "mod":
92
117
  return <ShieldAlert {...props} />;
118
+ case "level_up":
119
+ return <Award {...props} />;
93
120
  default:
94
121
  return <Bell {...props} />;
95
122
  }
@@ -97,8 +124,9 @@ function NotificationIcon({ type }: { type: string }) {
97
124
 
98
125
  export interface CommunityNotificationsInboxProps {
99
126
  /** Post notifications (entityType community_post) navigate here. Reply
100
- * notifications carry only the reply id — no public reply→post resolver yet,
101
- * so they render without navigation. */
127
+ * notifications carry only the reply id (no public reply to post resolver
128
+ * yet), so they render without navigation. Neither does a post that was not
129
+ * approved: nobody can open it. */
102
130
  onOpenPost?: (postId: string) => void;
103
131
  }
104
132
 
@@ -149,7 +177,10 @@ export function CommunityNotificationsInbox({ onOpenPost }: CommunityNotificatio
149
177
  key={n.id}
150
178
  notification={n}
151
179
  onOpen={
152
- n.entityType === "community_post" && n.entityId && onOpenPost
180
+ n.entityType === "community_post" &&
181
+ n.entityId &&
182
+ onOpenPost &&
183
+ describeModerationNotification(n)?.outcome !== "rejected"
153
184
  ? () => {
154
185
  if (!n.readAt) markRead.mutate([n.id]);
155
186
  onOpenPost(n.entityId!);
@@ -175,7 +206,19 @@ function NotificationRow({
175
206
  onMarkRead?: () => void;
176
207
  }) {
177
208
  const t = useThemeTokens();
209
+ const tr = useForgeT();
178
210
  const unread = !notification.readAt;
211
+ // A moderation outcome on the member's own post or reply. A mute notice
212
+ // (a `mod` about the community itself) has none and keeps its old copy.
213
+ const outcome = describeModerationNotification(notification);
214
+ const level = levelUpLevel(notification);
215
+ const title = outcome
216
+ ? tr(moderationOutcomeKey(outcome), { reason: outcome.reason ?? "" })
217
+ : notification.type === "level_up"
218
+ ? level !== null
219
+ ? tr("forge.community_notifications.level_up", { level })
220
+ : tr("forge.community_notifications.level_up_no_level")
221
+ : (NOTIFICATION_COPY[notification.type] ?? "Community update");
179
222
 
180
223
  return (
181
224
  <div
@@ -209,8 +252,11 @@ function NotificationRow({
209
252
  <NotificationIcon type={notification.type} />
210
253
  </span>
211
254
  <div style={{ flex: 1, minWidth: 0 }}>
212
- <div style={{ fontSize: 14, fontWeight: unread ? 600 : 400, color: t.text }}>
213
- {NOTIFICATION_COPY[notification.type] ?? "Community update"}
255
+ <div
256
+ data-testid="community-notification-title"
257
+ style={{ fontSize: 14, fontWeight: unread ? 600 : 400, color: t.text, overflowWrap: "anywhere" }}
258
+ >
259
+ {title}
214
260
  </div>
215
261
  <div style={{ fontSize: 12, color: t.muted }}>{timeAgo(notification.createdAt)}</div>
216
262
  </div>