@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
@@ -1,9 +1,15 @@
1
1
  import { useEffect, useId, useRef, useState } from "react";
2
- import { Bold, Italic, Underline, List, ListOrdered, Link as LinkIcon, PenLine } from "lucide-react";
2
+ import { BarChart3, Bold, Italic, Underline, List, ListOrdered, Link as LinkIcon, PenLine, Plus, X } from "lucide-react";
3
+ import { useForgeT } from "../../../i18n";
3
4
  import { useThemeTokens } from "../../theme/ForgeThemeProvider";
4
5
  import { formatBytes } from "../../headless/chat/attachmentKind";
5
6
  import { useCommunityAttachmentUpload } from "../../headless/community/useCommunityAttachmentUpload";
6
7
  import { useRichMentionComposer, type RichCommand } from "../../headless/community/useRichMentionComposer";
8
+ import {
9
+ COMMUNITY_POLL_LIMITS,
10
+ buildPollPayload,
11
+ useCommunityPollDraft,
12
+ } from "../../headless/community/communityPoll";
7
13
  // Forge's own `?` disclosure. It is named for the call screen it was first built
8
14
  // for, but it is a plain button + panel over the theme tokens with no media
9
15
  // dependency, so every styled surface uses it rather than growing a second one.
@@ -11,18 +17,50 @@ import { useRichMentionComposer, type RichCommand } from "../../headless/communi
11
17
  // is not a Forge dependency and would not resolve on a real creator site.
12
18
  import { CallHelpHint } from "../../media/CallHelpHint";
13
19
  import { MemberAvatar } from "./MemberAvatar";
14
- import type { CommunityAttachmentInput } from "../../../data/queries/useCommunity";
20
+ import { CommunityAgentAvatar, CommunityAgentName } from "./CommunityAgent";
21
+ import type {
22
+ CommunityAttachmentInput,
23
+ CommunityPollInput,
24
+ CommunityPollResultsVisibility,
25
+ } from "../../../data/queries/useCommunity";
15
26
 
16
27
  export interface CommunityComposerSubmit {
17
28
  title?: string;
18
29
  body: unknown;
19
30
  attachments: CommunityAttachmentInput[];
20
31
  mentionedAccountIds: string[];
32
+ /** Present when the member attached a poll. `title` is then its question. */
33
+ poll?: CommunityPollInput;
21
34
  }
22
35
 
36
+ const POLL_ERROR_KEY: Record<string, string> = {
37
+ question_required: "forge.community_composer.poll_question_required",
38
+ question_too_long: "forge.community_composer.poll_question_too_long",
39
+ option_empty: "forge.community_composer.poll_option_empty",
40
+ option_too_long: "forge.community_composer.poll_option_too_long",
41
+ option_duplicate: "forge.community_composer.poll_option_duplicate",
42
+ options_too_few: "forge.community_composer.poll_options_too_few",
43
+ options_too_many: "forge.community_composer.poll_options_too_many",
44
+ closes_invalid: "forge.community_composer.poll_closes_invalid",
45
+ closes_in_past: "forge.community_composer.poll_closes_in_past",
46
+ closes_required: "forge.community_composer.poll_closes_required",
47
+ visibility_invalid: "forge.community_composer.poll_visibility_invalid",
48
+ };
49
+
50
+ const VISIBILITY_KEY: Record<CommunityPollResultsVisibility, string> = {
51
+ always: "forge.community_composer.poll_visibility_always",
52
+ after_vote: "forge.community_composer.poll_visibility_after_vote",
53
+ after_close: "forge.community_composer.poll_visibility_after_close",
54
+ };
55
+
23
56
  export interface CommunityComposerProps {
24
57
  /** Show the title input (forum spaces). */
25
58
  withTitle?: boolean;
59
+ /**
60
+ * Offer a Poll toggle (the space feed's post box). The poll's question is
61
+ * the post title, so turning it on shows the title input whatever `withTitle` says.
62
+ */
63
+ allowPoll?: boolean;
26
64
  placeholder?: string;
27
65
  submitLabel?: string;
28
66
  autoFocus?: boolean;
@@ -72,6 +110,7 @@ export interface CommunityComposerProps {
72
110
  */
73
111
  export function CommunityComposer({
74
112
  withTitle = false,
113
+ allowPoll = false,
75
114
  placeholder = "Write something…",
76
115
  submitLabel = "Post",
77
116
  autoFocus = false,
@@ -84,7 +123,13 @@ export function CommunityComposer({
84
123
  onCancel,
85
124
  }: CommunityComposerProps) {
86
125
  const t = useThemeTokens();
126
+ const tr = useForgeT();
87
127
  const composer = useRichMentionComposer();
128
+ const poll = useCommunityPollDraft();
129
+ const [pollOn, setPollOn] = useState(false);
130
+ const withPoll = allowPoll && pollOn;
131
+ const showTitle = withTitle || withPoll;
132
+ const pollFieldId = useId();
88
133
  const upload = useCommunityAttachmentUpload();
89
134
  const [title, setTitle] = useState("");
90
135
  const [sending, setSending] = useState(false);
@@ -105,15 +150,18 @@ export function CommunityComposer({
105
150
  const [confirmingDiscard, setConfirmingDiscard] = useState(false);
106
151
 
107
152
  const border = `1px solid ${t.border}`;
108
- const canSubmit =
109
- (!composer.isEmpty || upload.hasPending) && !sending && (!withTitle || title.trim().length > 0);
153
+ // A poll may go out with no body: the question and options are the post. Its
154
+ // own checks run on submit, so the button stays live and a click shows why.
155
+ const canSubmit = withPoll
156
+ ? !sending
157
+ : (!composer.isEmpty || upload.hasPending) && !sending && (!withTitle || title.trim().length > 0);
110
158
  /** Is there work in here that closing would otherwise have thrown away? */
111
159
  const hasDraft = !composer.isEmpty || upload.hasPending || title.trim().length > 0;
112
160
 
113
161
  /** Put the caret where typing should carry on: the title first on a forum
114
162
  * post, otherwise the end of whatever is already written. */
115
163
  const focusEditor = () => {
116
- if (withTitle && titleRef.current) {
164
+ if (showTitle && titleRef.current) {
117
165
  titleRef.current.focus();
118
166
  return;
119
167
  }
@@ -142,25 +190,37 @@ export function CommunityComposer({
142
190
  composer.reset();
143
191
  setTitle("");
144
192
  upload.clear();
193
+ poll.reset();
194
+ setPollOn(false);
145
195
  setError(null);
146
196
  setConfirmingDiscard(false);
147
197
  };
148
198
 
199
+ const changeTitle = (value: string) => {
200
+ setTitle(value);
201
+ poll.setQuestion(value);
202
+ };
203
+
149
204
  const submit = async () => {
150
205
  if (!canSubmit) return;
206
+ if (withPoll && !poll.attempt()) return;
151
207
  setSending(true);
152
208
  setError(null);
153
209
  try {
154
210
  const attachments = upload.hasPending ? await upload.uploadAll() : [];
211
+ const pollPayload = withPoll ? buildPollPayload(poll.draft) : null;
155
212
  await onSubmit({
156
- title: withTitle ? title.trim() : undefined,
213
+ title: pollPayload ? pollPayload.title : withTitle ? title.trim() : undefined,
157
214
  body: composer.getBody(),
158
215
  attachments,
159
216
  mentionedAccountIds: composer.getMentionedAccountIds(),
217
+ ...(pollPayload ? { poll: pollPayload.poll } : {}),
160
218
  });
161
219
  composer.reset();
162
220
  setTitle("");
163
221
  upload.clear();
222
+ poll.reset();
223
+ setPollOn(false);
164
224
  // Posted, so the box has served its purpose and folds away. There is no
165
225
  // draft left to lose: reset() ran a line above.
166
226
  if (collapsible) setOpen(false);
@@ -285,13 +345,15 @@ export function CommunityComposer({
285
345
  id={bodyId}
286
346
  style={{ display: isOpen ? "flex" : "none", flexDirection: "column", gap: 8 }}
287
347
  >
288
- {withTitle && (
348
+ {showTitle && (
289
349
  <input
290
350
  ref={titleRef}
291
351
  value={title}
292
- onChange={(e) => setTitle(e.target.value)}
293
- placeholder="Title"
294
- maxLength={200}
352
+ onChange={(e) => changeTitle(e.target.value)}
353
+ placeholder={withPoll ? tr("forge.community_composer.poll_question_placeholder") : "Title"}
354
+ aria-label={withPoll ? tr("forge.community_composer.poll_question_label") : undefined}
355
+ aria-invalid={withPoll && poll.showErrors && !!poll.errors.question}
356
+ maxLength={COMMUNITY_POLL_LIMITS.questionMaxLength}
295
357
  style={{
296
358
  padding: "8px 10px",
297
359
  borderRadius: t.cornerRadius,
@@ -302,6 +364,11 @@ export function CommunityComposer({
302
364
  }}
303
365
  />
304
366
  )}
367
+ {withPoll && poll.showErrors && poll.errors.question && (
368
+ <div role="alert" style={{ fontSize: 12, color: "#dc2626" }}>
369
+ {tr(POLL_ERROR_KEY[poll.errors.question])}
370
+ </div>
371
+ )}
305
372
 
306
373
  {/* Formatting toolbar */}
307
374
  <div style={{ display: "flex", alignItems: "center", gap: 4, flexWrap: "wrap" }}>
@@ -358,7 +425,8 @@ export function CommunityComposer({
358
425
  color: ${t.muted};
359
426
  pointer-events: none;
360
427
  }
361
- .forge-community-composer-editable [data-account-id] {
428
+ .forge-community-composer-editable [data-account-id],
429
+ .forge-community-composer-editable [data-agent-id] {
362
430
  color: ${t.primary};
363
431
  font-weight: 600;
364
432
  background: ${t.primary}1a;
@@ -386,7 +454,8 @@ export function CommunityComposer({
386
454
  >
387
455
  {composer.suggestions.map((s) => (
388
456
  <button
389
- key={s.accountId}
457
+ key={s.agentId ? `agent:${s.agentId}` : s.accountId}
458
+ data-testid={s.agentId ? "community-mention-agent" : "community-mention-member"}
390
459
  type="button"
391
460
  onMouseDown={(e) => {
392
461
  // mousedown so the editor keeps focus.
@@ -407,14 +476,173 @@ export function CommunityComposer({
407
476
  fontSize: 14,
408
477
  }}
409
478
  >
410
- <MemberAvatar member={{ displayName: s.name, avatarUrl: s.avatarUrl }} size={24} />
411
- <span style={{ fontWeight: 500 }}>{s.name}</span>
479
+ {s.agentId ? (
480
+ <>
481
+ <CommunityAgentAvatar agent={s} size={24} />
482
+ <CommunityAgentName agent={s} fontWeight={500} />
483
+ </>
484
+ ) : (
485
+ <>
486
+ <MemberAvatar member={{ displayName: s.name, avatarUrl: s.avatarUrl }} size={24} />
487
+ <span style={{ fontWeight: 500 }}>{s.name}</span>
488
+ </>
489
+ )}
412
490
  </button>
413
491
  ))}
414
492
  </div>
415
493
  )}
416
494
  </div>
417
495
 
496
+ {withPoll && (
497
+ <fieldset
498
+ data-testid="community-composer-poll"
499
+ style={{ border, borderRadius: t.cornerRadius, padding: 10, margin: 0, minWidth: 0 }}
500
+ >
501
+ <legend style={{ padding: "0 4px", fontSize: 13, fontWeight: 600, color: t.text }}>
502
+ {tr("forge.community_composer.poll_options_legend")}
503
+ </legend>
504
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
505
+ {poll.draft.options.map((option, index) => {
506
+ const optionError = poll.showErrors ? poll.errors.options[index] : null;
507
+ const errorId = `${pollFieldId}-option-${index}-error`;
508
+ return (
509
+ <div key={index} style={{ display: "flex", flexDirection: "column", gap: 2 }}>
510
+ <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
511
+ <input
512
+ value={option}
513
+ onChange={(e) => poll.setOption(index, e.target.value)}
514
+ placeholder={tr("forge.community_composer.poll_option_placeholder", { number: index + 1 })}
515
+ aria-label={tr("forge.community_composer.poll_option_label", { number: index + 1 })}
516
+ aria-invalid={!!optionError}
517
+ aria-describedby={optionError ? errorId : undefined}
518
+ maxLength={COMMUNITY_POLL_LIMITS.optionMaxLength}
519
+ style={{
520
+ flex: 1,
521
+ minWidth: 0,
522
+ padding: "6px 10px",
523
+ borderRadius: t.cornerRadius,
524
+ border: `1px solid ${optionError ? "#dc2626" : t.border}`,
525
+ background: t.background,
526
+ color: t.text,
527
+ fontSize: 14,
528
+ }}
529
+ />
530
+ <button
531
+ type="button"
532
+ onClick={() => poll.removeOption(index)}
533
+ disabled={!poll.canRemoveOption}
534
+ aria-label={tr("forge.community_composer.poll_remove_option", { number: index + 1 })}
535
+ style={{
536
+ ...ghostButton,
537
+ padding: "4px 8px",
538
+ display: "inline-flex",
539
+ alignItems: "center",
540
+ cursor: poll.canRemoveOption ? "pointer" : "not-allowed",
541
+ opacity: poll.canRemoveOption ? 1 : 0.4,
542
+ }}
543
+ >
544
+ <X size={14} aria-hidden />
545
+ </button>
546
+ </div>
547
+ {optionError && (
548
+ <span id={errorId} role="alert" style={{ fontSize: 12, color: "#dc2626" }}>
549
+ {tr(POLL_ERROR_KEY[optionError])}
550
+ </span>
551
+ )}
552
+ </div>
553
+ );
554
+ })}
555
+ {poll.showErrors && poll.errors.optionsList && (
556
+ <span role="alert" style={{ fontSize: 12, color: "#dc2626" }}>
557
+ {tr(POLL_ERROR_KEY[poll.errors.optionsList])}
558
+ </span>
559
+ )}
560
+ <button
561
+ type="button"
562
+ onClick={poll.addOption}
563
+ disabled={!poll.canAddOption}
564
+ style={{
565
+ ...ghostButton,
566
+ alignSelf: "flex-start",
567
+ display: "inline-flex",
568
+ alignItems: "center",
569
+ gap: 4,
570
+ cursor: poll.canAddOption ? "pointer" : "not-allowed",
571
+ opacity: poll.canAddOption ? 1 : 0.5,
572
+ }}
573
+ >
574
+ <Plus size={14} aria-hidden />
575
+ {tr("forge.community_composer.poll_add_option")}
576
+ </button>
577
+ </div>
578
+
579
+ <label
580
+ style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 10, fontSize: 13, color: t.text }}
581
+ >
582
+ <input
583
+ type="checkbox"
584
+ role="switch"
585
+ checked={poll.draft.multipleChoice}
586
+ onChange={(e) => poll.setMultipleChoice(e.target.checked)}
587
+ style={{ accentColor: t.primary, margin: 0 }}
588
+ />
589
+ {tr("forge.community_composer.poll_multiple_choice")}
590
+ </label>
591
+
592
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 10, marginTop: 10 }}>
593
+ <label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13, color: t.text }}>
594
+ {tr("forge.community_composer.poll_closes_label")}
595
+ <input
596
+ type="datetime-local"
597
+ value={poll.draft.closesAt}
598
+ onChange={(e) => poll.setClosesAt(e.target.value)}
599
+ aria-invalid={poll.showErrors && !!poll.errors.closesAt}
600
+ style={{
601
+ padding: "6px 8px",
602
+ borderRadius: t.cornerRadius,
603
+ border: `1px solid ${poll.showErrors && poll.errors.closesAt ? "#dc2626" : t.border}`,
604
+ background: t.background,
605
+ color: t.text,
606
+ fontSize: 13,
607
+ }}
608
+ />
609
+ </label>
610
+ <div style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13, color: t.text }}>
611
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
612
+ <label htmlFor={`${pollFieldId}-visibility`}>{tr("forge.community_composer.poll_visibility_label")}</label>
613
+ <CallHelpHint label={tr("forge.community_composer.poll_visibility_help_label")}>
614
+ {tr("forge.community_composer.poll_visibility_help")}
615
+ </CallHelpHint>
616
+ </span>
617
+ <select
618
+ id={`${pollFieldId}-visibility`}
619
+ value={poll.draft.resultsVisibility}
620
+ onChange={(e) => poll.setResultsVisibility(e.target.value as CommunityPollResultsVisibility)}
621
+ style={{
622
+ padding: "6px 8px",
623
+ borderRadius: t.cornerRadius,
624
+ border,
625
+ background: t.background,
626
+ color: t.text,
627
+ fontSize: 13,
628
+ }}
629
+ >
630
+ {(Object.keys(VISIBILITY_KEY) as CommunityPollResultsVisibility[]).map((value) => (
631
+ <option key={value} value={value}>
632
+ {tr(VISIBILITY_KEY[value])}
633
+ </option>
634
+ ))}
635
+ </select>
636
+ </div>
637
+ </div>
638
+ {poll.showErrors && poll.errors.closesAt && (
639
+ <div role="alert" style={{ fontSize: 12, color: "#dc2626", marginTop: 4 }}>
640
+ {tr(POLL_ERROR_KEY[poll.errors.closesAt])}
641
+ </div>
642
+ )}
643
+ </fieldset>
644
+ )}
645
+
418
646
  {upload.error && <div style={{ fontSize: 12, color: "#dc2626" }}>{upload.error}</div>}
419
647
  {error && <div style={{ fontSize: 12, color: "#dc2626" }}>{error}</div>}
420
648
 
@@ -485,7 +713,30 @@ export function CommunityComposer({
485
713
  >
486
714
  📎
487
715
  </button>
488
- <span style={{ flex: 1, fontSize: 11, color: t.muted }}>Use @ to mention members</span>
716
+ {allowPoll && (
717
+ <button
718
+ type="button"
719
+ onClick={() => setPollOn((on) => !on)}
720
+ aria-pressed={pollOn}
721
+ style={{
722
+ ...ghostButton,
723
+ padding: "4px 10px",
724
+ display: "inline-flex",
725
+ alignItems: "center",
726
+ gap: 4,
727
+ background: pollOn ? `${t.primary}1a` : "none",
728
+ color: pollOn ? t.primary : t.text,
729
+ }}
730
+ >
731
+ <BarChart3 size={14} aria-hidden />
732
+ {tr("forge.community_composer.poll_toggle")}
733
+ </button>
734
+ )}
735
+ <span style={{ flex: 1, fontSize: 11, color: t.muted }}>
736
+ {composer.agent
737
+ ? tr("forge.community_composer.mention_hint_agent", { agent_name: composer.agent.name })
738
+ : tr("forge.community_composer.mention_hint")}
739
+ </span>
489
740
  {collapsible && (
490
741
  <>
491
742
  <CallHelpHint label="What happens to what I have typed?">
@@ -1,8 +1,10 @@
1
1
  import { useEffect, useState, type CSSProperties } from "react";
2
- import { MessageSquarePlus, Plus, Search, X } from "lucide-react";
2
+ import { Lock, MessageSquarePlus, Plus, Search, X } from "lucide-react";
3
+ import { useForgeT } from "../../../i18n";
3
4
  import { useThemeTokens } from "../../theme/ForgeThemeProvider";
4
5
  import { useCommunitySpaceFeed } from "../../headless/community/useCommunitySpaceFeed";
5
6
  import { useCommunitySearch, type CommunityPost, type CommunitySearchResults } from "../../../data/queries/useCommunity";
7
+ import { useCommunityAgent } from "../../../data/queries/useCommunityAgent";
6
8
  import { Loading } from "../Loading";
7
9
  import { CommunityComposer } from "./CommunityComposer";
8
10
  import { CommunityPostCard } from "./CommunityPostCard";
@@ -25,7 +27,10 @@ export interface CommunityFeedProps {
25
27
  */
26
28
  export function CommunityFeed({ spaceId: idOrSlug, onOpenPost, onOpenProfile, onBack }: CommunityFeedProps) {
27
29
  const t = useThemeTokens();
30
+ const tr = useForgeT();
28
31
  const feed = useCommunitySpaceFeed(idOrSlug);
32
+ // The community AI, for the name on its "thinking" line.
33
+ const agentInfo = useCommunityAgent();
29
34
  const border = `1px solid ${t.border}`;
30
35
 
31
36
  // Composer is closed by default. This drives the composer's `open` prop, and
@@ -59,6 +64,48 @@ export function CommunityFeed({ spaceId: idOrSlug, onOpenPost, onOpenProfile, on
59
64
  );
60
65
  }
61
66
 
67
+ if (feed.levelLocked) {
68
+ return (
69
+ <div
70
+ data-testid="community-feed-level-locked"
71
+ style={{
72
+ border,
73
+ borderRadius: t.cornerRadius,
74
+ padding: 32,
75
+ textAlign: "center",
76
+ display: "flex",
77
+ flexDirection: "column",
78
+ alignItems: "center",
79
+ gap: 8,
80
+ color: t.text,
81
+ }}
82
+ >
83
+ <Lock size={22} style={{ color: t.muted }} aria-hidden />
84
+ <div style={{ fontSize: 16, fontWeight: 600 }}>{feed.space.name}</div>
85
+ <div style={{ fontSize: 14, color: t.muted }}>
86
+ {tr("forge.community_feed.unlocks_at_level", { level: feed.minLevel })}
87
+ </div>
88
+ {onBack && (
89
+ <button
90
+ onClick={onBack}
91
+ style={{
92
+ marginTop: 6,
93
+ background: "none",
94
+ border,
95
+ borderRadius: t.cornerRadius,
96
+ padding: "6px 14px",
97
+ cursor: "pointer",
98
+ color: t.text,
99
+ fontSize: 13,
100
+ }}
101
+ >
102
+ {tr("forge.community_feed.back_to_spaces")}
103
+ </button>
104
+ )}
105
+ </div>
106
+ );
107
+ }
108
+
62
109
  const isForum = feed.space.type === "forum";
63
110
 
64
111
  const iconButton = (extra?: CSSProperties): CSSProperties => ({
@@ -257,6 +304,7 @@ export function CommunityFeed({ spaceId: idOrSlug, onOpenPost, onOpenProfile, on
257
304
  and the empty-state button drive the same state through `open`. */}
258
305
  <CommunityComposer
259
306
  collapsible
307
+ allowPoll
260
308
  open={composerOpen}
261
309
  onOpenChange={setComposerOpen}
262
310
  withTitle={isForum}
@@ -344,6 +392,7 @@ export function CommunityFeed({ spaceId: idOrSlug, onOpenPost, onOpenProfile, on
344
392
  onOpen={onOpenPost}
345
393
  onOpenProfile={onOpenProfile}
346
394
  onReact={(emoji) => feed.react(post.id, emoji)}
395
+ agentThinking={feed.isAgentThinking(post.id) ? { name: agentInfo.agent?.name } : null}
347
396
  />
348
397
  ))}
349
398
  </div>
@@ -0,0 +1,159 @@
1
+ import { useState, type CSSProperties } from "react";
2
+ import { Play } from "lucide-react";
3
+ import { useForgeT } from "../../../i18n";
4
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
5
+ import type { CommunityLinkPreview as CommunityLinkPreviewData } from "../../../data/queries/useCommunity";
6
+ import { autoplayEmbedUrl, linkPreviewDisplay, linkPreviewHost } from "../../headless/community/linkPreview";
7
+
8
+ export interface CommunityLinkPreviewProps {
9
+ preview: CommunityLinkPreviewData | null | undefined;
10
+ className?: string;
11
+ style?: CSSProperties;
12
+ }
13
+
14
+ /** A link out of the community: a stranger's page, so it gets no referrer, no ranking credit and no opener. */
15
+ const LINK_REL = "noopener noreferrer nofollow ugc";
16
+
17
+ /**
18
+ * The card under a post for its first link. A plain link shows its image, site
19
+ * name, title and description, and opens in a new tab. A YouTube, Vimeo or Loom
20
+ * video shows its thumbnail and a play button, and the player is only loaded
21
+ * once the member presses play, so reading the feed tells no video site who is
22
+ * reading it. A video from anywhere else is shown as a link card.
23
+ */
24
+ export function CommunityLinkPreview({ preview, className, style }: CommunityLinkPreviewProps) {
25
+ const t = useThemeTokens();
26
+ const tr = useForgeT();
27
+ const [playing, setPlaying] = useState(false);
28
+ const display = linkPreviewDisplay(preview);
29
+ if (!preview || !display) return null;
30
+
31
+ const siteName = preview.siteName?.trim() || linkPreviewHost(display.href);
32
+ const title = preview.title?.trim() || null;
33
+ const frame: CSSProperties = {
34
+ marginTop: 12,
35
+ border: `1px solid ${t.border}`,
36
+ borderRadius: t.cornerRadius,
37
+ overflow: "hidden",
38
+ background: t.surface,
39
+ ...style,
40
+ };
41
+
42
+ if (display.kind === "video") {
43
+ return (
44
+ <div data-testid="community-link-video" className={className} style={frame}>
45
+ <div style={{ position: "relative", width: "100%", aspectRatio: "16 / 9", background: t.background }}>
46
+ {playing ? (
47
+ <iframe
48
+ data-testid="community-link-video-frame"
49
+ src={autoplayEmbedUrl(display.embedUrl)}
50
+ title={title ?? tr("forge.community_link_preview.video_frame")}
51
+ loading="lazy"
52
+ allow="autoplay; fullscreen; picture-in-picture"
53
+ allowFullScreen
54
+ referrerPolicy="strict-origin-when-cross-origin"
55
+ style={{ position: "absolute", inset: 0, width: "100%", height: "100%", border: 0 }}
56
+ />
57
+ ) : (
58
+ <button
59
+ type="button"
60
+ data-testid="community-link-video-play"
61
+ onClick={() => setPlaying(true)}
62
+ aria-label={title ? tr("forge.community_link_preview.play_title", { title }) : tr("forge.community_link_preview.play")}
63
+ style={{
64
+ position: "absolute",
65
+ inset: 0,
66
+ width: "100%",
67
+ height: "100%",
68
+ padding: 0,
69
+ border: "none",
70
+ cursor: "pointer",
71
+ background: "transparent",
72
+ }}
73
+ >
74
+ {display.imageUrl && (
75
+ <img
76
+ src={display.imageUrl}
77
+ alt=""
78
+ loading="lazy"
79
+ referrerPolicy="no-referrer"
80
+ style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }}
81
+ />
82
+ )}
83
+ <span
84
+ aria-hidden
85
+ style={{
86
+ position: "absolute",
87
+ top: "50%",
88
+ left: "50%",
89
+ transform: "translate(-50%, -50%)",
90
+ width: 60,
91
+ height: 60,
92
+ borderRadius: 999,
93
+ display: "flex",
94
+ alignItems: "center",
95
+ justifyContent: "center",
96
+ background: t.primary,
97
+ color: t.textPrimary,
98
+ }}
99
+ >
100
+ <Play size={26} fill="currentColor" />
101
+ </span>
102
+ </button>
103
+ )}
104
+ </div>
105
+ {(title || siteName) && (
106
+ <a
107
+ href={display.href}
108
+ target="_blank"
109
+ rel={LINK_REL}
110
+ style={{ display: "block", padding: "10px 14px", textDecoration: "none", color: t.text }}
111
+ >
112
+ {siteName && <span style={{ display: "block", fontSize: 12, color: t.muted }}>{siteName}</span>}
113
+ {title && <span style={{ display: "block", fontSize: 14, fontWeight: 600 }}>{title}</span>}
114
+ </a>
115
+ )}
116
+ </div>
117
+ );
118
+ }
119
+
120
+ return (
121
+ <a
122
+ data-testid="community-link-card"
123
+ href={display.href}
124
+ target="_blank"
125
+ rel={LINK_REL}
126
+ className={className}
127
+ style={{ ...frame, display: "block", textDecoration: "none", color: t.text }}
128
+ >
129
+ {display.imageUrl && (
130
+ <img
131
+ src={display.imageUrl}
132
+ alt=""
133
+ loading="lazy"
134
+ referrerPolicy="no-referrer"
135
+ style={{ display: "block", width: "100%", maxHeight: 280, objectFit: "cover", background: t.background }}
136
+ />
137
+ )}
138
+ <span style={{ display: "flex", flexDirection: "column", gap: 3, padding: "10px 14px" }}>
139
+ {siteName && <span style={{ fontSize: 12, color: t.muted }}>{siteName}</span>}
140
+ <span style={{ fontSize: 14, fontWeight: 600, overflowWrap: "anywhere" }}>{title ?? display.href}</span>
141
+ {preview.description?.trim() && (
142
+ <span
143
+ style={{
144
+ fontSize: 13,
145
+ color: t.muted,
146
+ display: "-webkit-box",
147
+ WebkitLineClamp: 2,
148
+ WebkitBoxOrient: "vertical",
149
+ overflow: "hidden",
150
+ }}
151
+ >
152
+ {preview.description.trim()}
153
+ </span>
154
+ )}
155
+ <span className="fg:sr-only">{tr("forge.community_link_preview.new_tab")}</span>
156
+ </span>
157
+ </a>
158
+ );
159
+ }