@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
@@ -1,6 +1,15 @@
1
- import { useInfiniteQuery, useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
1
+ import { useEffect, useState } from "react";
2
+ import {
3
+ keepPreviousData,
4
+ useInfiniteQuery,
5
+ useMutation,
6
+ useQuery,
7
+ useQueryClient,
8
+ type QueryClient,
9
+ } from "@tanstack/react-query";
2
10
  import { useCommunityScope } from "../../contexts/CommunityContext";
3
11
  import { useChatSocket } from "./useChat";
12
+ import type { CommunityAgentAuthor } from "./useCommunityAgent";
4
13
 
5
14
  // ── Types (mirror apps/backend/src/routes/public/community + models) ─────────
6
15
 
@@ -25,6 +34,14 @@ export interface CommunitySpace {
25
34
  memberCount?: number | null;
26
35
  /** Whether the current member has joined this space (new-post notifications). */
27
36
  isJoined?: boolean;
37
+ /** The level a member needs to read this space (1 = no level needed). */
38
+ minLevel?: number;
39
+ /**
40
+ * On the member list: the space is above the member's level. It is listed so
41
+ * they can see what their next level opens, but its feed stays closed (the
42
+ * server refuses it too).
43
+ */
44
+ levelLocked?: boolean;
28
45
  }
29
46
 
30
47
  /** One roster row (GET /public/community/spaces/:id/members). */
@@ -80,7 +97,8 @@ export interface CommunityPost {
80
97
  id: string;
81
98
  profileId: string;
82
99
  spaceId: string;
83
- authorAccountId: string;
100
+ /** Null when the community AI wrote it (`agentAuthor` is then set). */
101
+ authorAccountId: string | null;
84
102
  title: string | null;
85
103
  /** Lexical editor state (JSON). Render with <CommunityRichText/>. */
86
104
  body: unknown;
@@ -92,22 +110,119 @@ export interface CommunityPost {
92
110
  lastActivityAt: string | null;
93
111
  createdAt: string;
94
112
  author: CommunityAuthor | null;
113
+ /** The community AI, when it wrote this post. `author` is then null. Absent on older servers. */
114
+ agentAuthor?: CommunityAgentAuthor | null;
95
115
  media: CommunityMediaItem[];
96
116
  reactions?: ReactionSummary[];
117
+ /**
118
+ * Moderation state. Only an author sees their own pending post; everyone else
119
+ * sees published posts only. Absent on rows from older servers: treat as published.
120
+ */
121
+ status?: CommunityContentStatus;
122
+ /** Why a post was held (`approval_new_member`, `approval_all`, `keyword:<word>`), or why it was rejected. */
123
+ moderationReason?: string | null;
124
+ /** Set in the cache when a moderator removes the post in this session, so the undo stays in place. */
125
+ archivedAt?: string | null;
126
+ /**
127
+ * The post's poll, as this viewer sees it, or null when it has none. Absent
128
+ * on a post that arrived over the realtime socket (see `CommunityPollView`).
129
+ */
130
+ poll?: CommunityPollView | null;
131
+ /**
132
+ * A card for the first link in the post, or null. Filled in the background
133
+ * shortly after the post is published, so a new post has none at first.
134
+ * Absent on rows from older servers.
135
+ */
136
+ linkPreview?: CommunityLinkPreview | null;
137
+ }
138
+
139
+ /** A link card on a post (community parity §6.2). */
140
+ export interface CommunityLinkPreview {
141
+ url: string;
142
+ kind: "link" | "video";
143
+ provider: "youtube" | "vimeo" | "loom" | null;
144
+ siteName: string | null;
145
+ title: string | null;
146
+ description: string | null;
147
+ imageUrl: string | null;
148
+ /** The player address, for a video. Render only through `safeEmbedUrl`. */
149
+ embedUrl: string | null;
150
+ }
151
+
152
+ // ── Polls (community parity §6.1) ────────────────────────────────────────────
153
+
154
+ /** When a poll's counts are shown: at once, once the member voted, or once it closed. */
155
+ export type CommunityPollResultsVisibility = "always" | "after_vote" | "after_close";
156
+
157
+ export const COMMUNITY_POLL_RESULTS_VISIBILITY: readonly CommunityPollResultsVisibility[] = [
158
+ "always",
159
+ "after_vote",
160
+ "after_close",
161
+ ];
162
+
163
+ export interface CommunityPollOption {
164
+ id: string;
165
+ label: string;
166
+ position: number;
167
+ /** Null while this viewer may not see results yet. */
168
+ votes: number | null;
169
+ }
170
+
171
+ /** A post's poll as the viewer sees it. The post title is its question. */
172
+ export interface CommunityPollView {
173
+ id: string;
174
+ multipleChoice: boolean;
175
+ closesAt: string | null;
176
+ closed: boolean;
177
+ resultsVisibility: CommunityPollResultsVisibility;
178
+ /** Whether this viewer may see the counts now. */
179
+ resultsVisible: boolean;
180
+ /** Distinct members who voted. Null while results are hidden. */
181
+ totalVoters: number | null;
182
+ /** The option ids this viewer chose. Empty when they have not voted. */
183
+ myVotes: string[];
184
+ options: CommunityPollOption[];
185
+ }
186
+
187
+ /** The poll part of the create-post body. The question travels as the post `title`. */
188
+ export interface CommunityPollInput {
189
+ options: string[];
190
+ multipleChoice?: boolean;
191
+ /** ISO date, in the future. */
192
+ closesAt?: string | null;
193
+ resultsVisibility?: CommunityPollResultsVisibility;
194
+ }
195
+
196
+ /** Where a post or reply stands in moderation. */
197
+ export type CommunityContentStatus = "published" | "pending" | "rejected";
198
+
199
+ /** True when a post or reply is waiting for a moderator (only its author ever sees it). */
200
+ export function isCommunityContentPending(
201
+ item: { status?: CommunityContentStatus | string | null } | null | undefined,
202
+ ): boolean {
203
+ return item?.status === "pending";
97
204
  }
98
205
 
99
206
  export interface CommunityReply {
100
207
  id: string;
101
208
  postId: string;
102
209
  parentReplyId: string | null;
103
- authorAccountId: string;
210
+ /** Null when the community AI wrote it (`agentAuthor` is then set). */
211
+ authorAccountId: string | null;
104
212
  body: unknown;
105
213
  bodyText: string | null;
106
214
  reactionCount: number;
107
215
  createdAt: string;
108
216
  author: CommunityAuthor | null;
217
+ /** The community AI, when this reply is its answer. `author` is then null. */
218
+ agentAuthor?: CommunityAgentAuthor | null;
109
219
  media: CommunityMediaItem[];
110
220
  reactions?: ReactionSummary[];
221
+ /** See `CommunityPost.status`. */
222
+ status?: CommunityContentStatus;
223
+ moderationReason?: string | null;
224
+ /** Set in the cache when a moderator removes the reply in this session. */
225
+ archivedAt?: string | null;
111
226
  }
112
227
 
113
228
  /** Attachment input shape the community create endpoints accept. */
@@ -136,6 +251,20 @@ export interface CommunityMemberProfile {
136
251
  createdAt: string;
137
252
  firstName: string | null;
138
253
  lastName: string | null;
254
+ /**
255
+ * Only on `/me`: whether this member moderates THIS community (the moderator
256
+ * role here, or an owner/admin of the profile). Display only; the server
257
+ * checks again on every moderation call.
258
+ */
259
+ canModerate?: boolean;
260
+ /** Listed in the member directory (default true). */
261
+ showInDirectory?: boolean;
262
+ /** Counted on the member map, by country only (default false). */
263
+ showOnMap?: boolean;
264
+ /** Free text, display only. */
265
+ city?: string | null;
266
+ /** ISO 3166-1 alpha-2, or null. */
267
+ countryCode?: string | null;
139
268
  recentPosts?: {
140
269
  id: string;
141
270
  spaceId: string;
@@ -158,17 +287,30 @@ export interface CommunityMemberProfile {
158
287
 
159
288
  export type CommunityDigestCadence = "off" | "inapp_only" | "hourly" | "daily";
160
289
 
161
- export type CommunityNotificationType = "mention" | "reply" | "reaction" | "new_post" | "mod";
290
+ export type CommunityNotificationType = "mention" | "reply" | "reaction" | "new_post" | "mod" | "level_up";
162
291
 
163
292
  export interface CommunityNotificationItem {
164
293
  id: string;
165
294
  profileId: string;
166
295
  accountId: string;
167
296
  type: CommunityNotificationType | string;
168
- entityType: "community_post" | "community_reply" | string | null;
297
+ entityType: "community_post" | "community_reply" | "community" | string | null;
169
298
  entityId: string | null;
170
299
  readAt: string | null;
171
300
  createdAt: string;
301
+ /**
302
+ * Extra detail for some types. A `mod` notice about a post or reply carries
303
+ * the moderation outcome; a `mod` notice about the community (a mute) has none.
304
+ */
305
+ data?: CommunityNotificationData | null;
306
+ }
307
+
308
+ export interface CommunityNotificationData {
309
+ outcome?: "approved" | "rejected";
310
+ reason?: string | null;
311
+ /** On a `level_up`: the level the member reached. */
312
+ level?: number;
313
+ [key: string]: unknown;
172
314
  }
173
315
 
174
316
  export interface CommunityLeaderboardEntry {
@@ -251,6 +393,9 @@ export const communityKeys = {
251
393
  leaderboard: (communityId?: string, period?: string) => ["public-community-leaderboard", communityId, period] as const,
252
394
  search: (communityId?: string, q?: string, spaceId?: string) =>
253
395
  ["public-community-search", communityId, q, spaceId ?? null] as const,
396
+ moderationQueue: (communityId?: string) => ["public-community-moderation-queue", communityId] as const,
397
+ moderationLog: (communityId?: string, page?: number) =>
398
+ ["public-community-moderation-log", communityId, page] as const,
254
399
  };
255
400
 
256
401
  // ── Data hooks ───────────────────────────────────────────────────────────────
@@ -401,6 +546,8 @@ export interface CreateCommunityPostInput {
401
546
  body: unknown;
402
547
  attachments?: CommunityAttachmentInput[];
403
548
  mentionedAccountIds?: string[];
549
+ /** Attach a poll. `title` is then its question, and required. */
550
+ poll?: CommunityPollInput;
404
551
  }
405
552
 
406
553
  /** Create a post in a space (POST /public/community/spaces/:id/posts). */
@@ -419,6 +566,41 @@ export function useCreateCommunityPost(spaceId?: string) {
419
566
  });
420
567
  }
421
568
 
569
+ export interface VoteCommunityPollInput {
570
+ postId: string;
571
+ /**
572
+ * The member's WHOLE answer: it replaces any earlier vote. An empty list
573
+ * withdraws the vote.
574
+ */
575
+ optionIds: string[];
576
+ /** Cache hint: the feed the post sits in. Omitted, every loaded feed is patched. */
577
+ spaceId?: string;
578
+ }
579
+
580
+ /**
581
+ * Vote on a post's poll (POST /public/community/posts/:id/poll/vote). Answers
582
+ * the updated poll, which lands in the feed and post caches. The server answers
583
+ * 409 once the poll has closed, 400 for an option that is not on it (or several
584
+ * on a single-choice poll), and 403 to a muted member or one without access.
585
+ */
586
+ export function useVoteCommunityPoll() {
587
+ const { client, profileId, communityId } = useCommunityScope("useVoteCommunityPoll()");
588
+ const queryClient = useQueryClient();
589
+ return useMutation({
590
+ mutationFn: async (input: VoteCommunityPollInput) => {
591
+ const res = await client.post(`/public/community/posts/${input.postId}/poll/vote`, {
592
+ profileId,
593
+ communityId,
594
+ optionIds: input.optionIds,
595
+ });
596
+ return { poll: res.data as CommunityPollView, input };
597
+ },
598
+ onSuccess: ({ poll, input }) => {
599
+ setPostPoll(queryClient, { postId: input.postId, spaceId: input.spaceId }, poll);
600
+ },
601
+ });
602
+ }
603
+
422
604
  export interface CreateCommunityReplyInput {
423
605
  body: unknown;
424
606
  parentReplyId?: string;
@@ -513,6 +695,17 @@ export function useMyCommunityProfile(enabled = true) {
513
695
  });
514
696
  }
515
697
 
698
+ /**
699
+ * Whether the signed-in member moderates THIS community (`canModerate` on
700
+ * `/me`), for showing the moderation tools. Display only: every moderation
701
+ * call is checked again by the server. Reads the same cache entry as
702
+ * `useMyCommunityProfile`, and stays false (with no request) until `enabled`.
703
+ */
704
+ export function useCommunityCanModerate(enabled = true): boolean {
705
+ const { data } = useMyCommunityProfile(enabled);
706
+ return enabled && !!data?.canModerate;
707
+ }
708
+
516
709
  /** Update display name / bio / avatar / digest cadence (PUT /public/community/me). */
517
710
  export function useUpdateMyCommunityProfile() {
518
711
  const { client, profileId, communityId } = useCommunityScope("useUpdateMyCommunityProfile()");
@@ -523,13 +716,24 @@ export function useUpdateMyCommunityProfile() {
523
716
  bio?: string | null;
524
717
  avatarUrl?: string | null;
525
718
  digestCadence?: CommunityDigestCadence;
719
+ showInDirectory?: boolean;
720
+ showOnMap?: boolean;
721
+ city?: string | null;
722
+ countryCode?: string | null;
526
723
  }) => {
527
724
  const res = await client.put("/public/community/me", { profileId, communityId, ...input });
528
725
  return res.data as CommunityMemberProfile;
529
726
  },
530
727
  onSuccess: (profile) => {
531
- queryClient.setQueryData(communityKeys.myProfile(communityId), profile);
728
+ // Merged, not replaced: `/me` carries fields the PUT answer may not
729
+ // (`canModerate`), and dropping them would hide the moderator's tools.
730
+ queryClient.setQueryData(communityKeys.myProfile(communityId), (old: CommunityMemberProfile | undefined) =>
731
+ old ? { ...old, ...profile } : profile,
732
+ );
532
733
  queryClient.invalidateQueries({ queryKey: communityKeys.member(communityId, profile?.accountId) });
734
+ // Directory and map visibility may have changed.
735
+ queryClient.invalidateQueries({ queryKey: ["public-community-directory", communityId] });
736
+ queryClient.invalidateQueries({ queryKey: ["public-community-member-map", communityId] });
533
737
  },
534
738
  });
535
739
  }
@@ -606,24 +810,59 @@ export function useCommunityLeaderboard(period: CommunityLeaderboardPeriod = "al
606
810
  });
607
811
  }
608
812
 
813
+ /** How long the typed @query settles before the member search runs. */
814
+ export const MENTION_SEARCH_DEBOUNCE_MS = 150;
815
+
609
816
  /**
610
- * Member directory for @mention autocomplete. There is no dedicated public
611
- * member-search endpoint yet, so this reads the all-time leaderboard at its max
612
- * page size (100) (every community_profiles row ranked by points) and filters
613
- * client-side.
817
+ * The member search behind @mention: the typed text as `q` (left out when
818
+ * blank, so a bare "@" lists the most active members), ten results, most
819
+ * recently active first.
614
820
  */
615
- export function useCommunityMentionDirectory() {
821
+ export function mentionDirectoryParams(q: string | null | undefined): Record<string, string | number> {
822
+ const trimmed = (q ?? "").trim().slice(0, 80);
823
+ return { ...(trimmed ? { q: trimmed } : {}), limit: 10, sort: "active" };
824
+ }
825
+
826
+ /**
827
+ * Members for @mention autocomplete, searched on the server as the member
828
+ * types (GET /public/community/members), so every current member can be
829
+ * mentioned, not just the hundred with the most points. Pass the text after
830
+ * the "@"; pass `undefined` while no @token is active and nothing is asked
831
+ * for. Rows come back in the leaderboard entry shape the composers read.
832
+ */
833
+ export function useCommunityMentionDirectory(query?: string | null) {
616
834
  const { client, profileId, communityId } = useCommunityScope("useCommunityMentionDirectory()");
835
+ const active = query !== undefined && query !== null;
836
+ // null while no @token is open. The first text after "@" is searched at
837
+ // once; typing after that settles first, so a request goes per pause.
838
+ const [settled, setSettled] = useState<string | null>(null);
839
+ useEffect(() => {
840
+ if (!active) {
841
+ setSettled(null);
842
+ return;
843
+ }
844
+ const id = setTimeout(() => setSettled(query ?? ""), MENTION_SEARCH_DEBOUNCE_MS);
845
+ return () => clearTimeout(id);
846
+ }, [query, active]);
847
+ const params = mentionDirectoryParams(settled ?? query);
617
848
  return useQuery<CommunityLeaderboardEntry[]>({
618
- queryKey: communityKeys.leaderboard(communityId, "mention-directory"),
849
+ queryKey: ["public-community-mention-search", communityId, params] as const,
619
850
  queryFn: async () => {
620
- const res = await client.get("/public/community/leaderboard", {
621
- params: { profileId, communityId, period: "all", limit: 100 },
622
- });
623
- return res.data;
851
+ const res = await client.get("/public/community/members", { params: { profileId, communityId, ...params } });
852
+ const rows = (res.data?.data ?? []) as Array<CommunityLeaderboardEntry & { points?: number }>;
853
+ return rows.map((m) => ({
854
+ accountId: m.accountId,
855
+ displayName: m.displayName,
856
+ avatarUrl: m.avatarUrl,
857
+ points: m.points ?? 0,
858
+ level: m.level,
859
+ firstName: m.firstName,
860
+ lastName: m.lastName,
861
+ }));
624
862
  },
625
- enabled: !!profileId && !!communityId && !!client,
626
- staleTime: 60_000,
863
+ placeholderData: keepPreviousData,
864
+ enabled: active && !!profileId && !!communityId && !!client,
865
+ staleTime: 30_000,
627
866
  });
628
867
  }
629
868
 
@@ -718,6 +957,32 @@ export function bumpFeedReplyCount(queryClient: QueryClient, spaceId: string | u
718
957
  });
719
958
  }
720
959
 
960
+ /**
961
+ * Put a post's updated poll into every cache that renders the post: its feed
962
+ * (the one named, or every loaded feed when none is) and the single-post read.
963
+ */
964
+ export function setPostPoll(
965
+ queryClient: QueryClient,
966
+ target: { postId: string; spaceId?: string },
967
+ poll: CommunityPollView | null,
968
+ ) {
969
+ const patchFeed = (old: any) => {
970
+ if (!old?.pages?.length) return old;
971
+ return {
972
+ ...old,
973
+ pages: old.pages.map((page: any) => ({
974
+ ...page,
975
+ data: page.data.map((p: CommunityPost) => (p.id === target.postId ? { ...p, poll } : p)),
976
+ })),
977
+ };
978
+ };
979
+ if (target.spaceId) queryClient.setQueryData(communityKeys.posts(target.spaceId), patchFeed);
980
+ else queryClient.setQueriesData({ queryKey: ["public-community-posts"] }, patchFeed);
981
+ queryClient.setQueryData(communityKeys.post(target.postId), (old: CommunityPost | undefined) =>
982
+ old ? { ...old, poll } : old,
983
+ );
984
+ }
985
+
721
986
  /** Replace an entity's reaction summary in every cache that renders it. */
722
987
  export function setReactionSummary(
723
988
  queryClient: QueryClient,
@@ -796,3 +1061,82 @@ export function applyReactionDelta(
796
1061
  });
797
1062
  }
798
1063
  }
1064
+
1065
+ /** The `communityContentHeld` socket payload: auto-moderation sent it back to the approval queue. */
1066
+ export interface CommunityContentHeldEvent {
1067
+ spaceId: string;
1068
+ entityType: "community_post" | "community_reply";
1069
+ postId: string | null;
1070
+ replyId: string | null;
1071
+ }
1072
+
1073
+ /**
1074
+ * Take a post or reply that was just held for approval out of the open feeds
1075
+ * and threads. Everyone else loses it at once; its author keeps it, marked as
1076
+ * waiting for approval, exactly as the server shows them their own held work.
1077
+ * An open post that is held for a reader who is not its author becomes `null`,
1078
+ * which the thread renders as unavailable.
1079
+ */
1080
+ export function applyCommunityContentHeld(
1081
+ queryClient: QueryClient,
1082
+ event: CommunityContentHeldEvent,
1083
+ viewerAccountId: string | null | undefined,
1084
+ ) {
1085
+ const mine = (authorAccountId: string | null | undefined) => !!viewerAccountId && authorAccountId === viewerAccountId;
1086
+ const asPending = <T extends { status?: CommunityContentStatus }>(item: T): T => ({ ...item, status: "pending" });
1087
+
1088
+ if (event.entityType === "community_post" && event.postId) {
1089
+ const postId = event.postId;
1090
+ queryClient.setQueryData(communityKeys.posts(event.spaceId), (old: any) => {
1091
+ if (!old?.pages?.length) return old;
1092
+ return {
1093
+ ...old,
1094
+ pages: old.pages.map((page: any) => {
1095
+ const had = page.data.some((p: CommunityPost) => p.id === postId);
1096
+ if (!had) return page;
1097
+ const data = page.data.flatMap((p: CommunityPost) =>
1098
+ p.id !== postId ? [p] : mine(p.authorAccountId) ? [asPending(p)] : [],
1099
+ );
1100
+ return { ...page, data, total: Math.max(0, page.total - (page.data.length - data.length)) };
1101
+ }),
1102
+ };
1103
+ });
1104
+ queryClient.setQueryData(communityKeys.post(postId), (old: CommunityPost | null | undefined) =>
1105
+ old ? (mine(old.authorAccountId) ? asPending(old) : null) : old,
1106
+ );
1107
+ return;
1108
+ }
1109
+
1110
+ if (event.entityType === "community_reply" && event.postId && event.replyId) {
1111
+ const { postId, replyId } = event;
1112
+ let removed = false;
1113
+ queryClient.setQueryData(communityKeys.replies(postId), (old: any) => {
1114
+ if (!old?.pages?.length) return old;
1115
+ return {
1116
+ ...old,
1117
+ pages: old.pages.map((page: any) => {
1118
+ if (!page.data.some((r: CommunityReply) => r.id === replyId)) return page;
1119
+ const data = page.data.flatMap((r: CommunityReply) =>
1120
+ r.id !== replyId ? [r] : mine(r.authorAccountId) ? [asPending(r)] : [],
1121
+ );
1122
+ if (data.length < page.data.length) removed = true;
1123
+ return { ...page, data, total: Math.max(0, page.total - (page.data.length - data.length)) };
1124
+ }),
1125
+ };
1126
+ });
1127
+ if (removed) {
1128
+ const drop = (p: CommunityPost) => ({ ...p, replyCount: Math.max(0, (p.replyCount ?? 0) - 1) });
1129
+ queryClient.setQueryData(communityKeys.post(postId), (old: CommunityPost | null | undefined) => (old ? drop(old) : old));
1130
+ queryClient.setQueryData(communityKeys.posts(event.spaceId), (old: any) => {
1131
+ if (!old?.pages?.length) return old;
1132
+ return {
1133
+ ...old,
1134
+ pages: old.pages.map((page: any) => ({
1135
+ ...page,
1136
+ data: page.data.map((p: CommunityPost) => (p.id === postId ? drop(p) : p)),
1137
+ })),
1138
+ };
1139
+ });
1140
+ }
1141
+ }
1142
+ }