@tribe-nest/forge 3.60.0 โ†’ 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/package.json +2 -1
  2. package/src/contexts/CartContext.tsx +34 -1
  3. package/src/contexts/PublicAuthContext.tsx +15 -3
  4. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  5. package/src/data/queries/useAccount.ts +33 -2
  6. package/src/data/queries/useAnalytics.ts +6 -6
  7. package/src/data/queries/useCallChat.ts +2 -2
  8. package/src/data/queries/useChat.ts +10 -1
  9. package/src/data/queries/useCheckouts.ts +2 -1
  10. package/src/data/queries/useCoachingAvailability.ts +10 -1
  11. package/src/data/queries/useCommunities.ts +2 -0
  12. package/src/data/queries/useCommunity.ts +362 -18
  13. package/src/data/queries/useCommunityAgent.ts +247 -0
  14. package/src/data/queries/useCommunityCalendar.ts +150 -0
  15. package/src/data/queries/useCommunityClassroom.ts +75 -0
  16. package/src/data/queries/useCommunityMembers.ts +108 -0
  17. package/src/data/queries/useCommunityModeration.ts +327 -0
  18. package/src/data/queries/useCommunityWiki.ts +137 -0
  19. package/src/data/queries/useCourses.ts +2 -0
  20. package/src/data/queries/useEmailSignIn.ts +79 -0
  21. package/src/data/queries/useEvents.ts +6 -1
  22. package/src/data/queries/useForms.ts +5 -1
  23. package/src/data/queries/useLeadMagnet.ts +6 -2
  24. package/src/data/queries/useMyBookings.ts +24 -1
  25. package/src/data/queries/useMyTickets.ts +24 -1
  26. package/src/data/queries/useNotifications.ts +6 -2
  27. package/src/data/queries/useOrders.ts +2 -1
  28. package/src/data/queries/usePWA.ts +111 -35
  29. package/src/data/queries/useSiteApp.ts +130 -0
  30. package/src/data/queries/useSubscriptions.ts +6 -1
  31. package/src/i18n/de.json +746 -1
  32. package/src/i18n/en.json +746 -1
  33. package/src/index.ts +17 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/types/models.ts +11 -0
  37. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  38. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  39. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  40. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  41. package/src/ui/analytics/useMetaEvents.ts +78 -0
  42. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  43. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  44. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  45. package/src/ui/headless/_tests/communityHarness.tsx +10 -3
  46. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  47. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  48. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  49. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  50. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  51. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  52. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  53. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  54. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  55. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  56. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  57. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  58. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  59. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  60. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  61. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  62. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  63. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  64. package/src/ui/headless/communities/index.ts +41 -0
  65. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  66. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  67. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  68. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  69. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  70. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  71. package/src/ui/headless/community/communityPoll.ts +297 -0
  72. package/src/ui/headless/community/countries.ts +123 -0
  73. package/src/ui/headless/community/index.ts +81 -1
  74. package/src/ui/headless/community/linkPreview.ts +71 -0
  75. package/src/ui/headless/community/richLexical.ts +27 -2
  76. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  77. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  78. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  79. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  80. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  81. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  82. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  83. package/src/ui/headless/downloads/index.ts +8 -0
  84. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  85. package/src/ui/headless/index.ts +13 -0
  86. package/src/ui/headless/memberHome/index.ts +10 -0
  87. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  88. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  89. package/src/ui/headless/portal/index.ts +57 -0
  90. package/src/ui/headless/portal/usePortal.ts +274 -0
  91. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  92. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  93. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  94. package/src/ui/headless/siteApp/appBar.ts +102 -0
  95. package/src/ui/headless/siteApp/appHome.ts +344 -0
  96. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  97. package/src/ui/headless/siteApp/index.ts +58 -0
  98. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  99. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  100. package/src/ui/headless/work/index.ts +2 -0
  101. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  102. package/src/ui/index.ts +11 -0
  103. package/src/ui/styled/AccountDashboard.tsx +712 -352
  104. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  105. package/src/ui/styled/ChatRoom.tsx +126 -16
  106. package/src/ui/styled/Checkout.tsx +8 -0
  107. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  108. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  109. package/src/ui/styled/CoachingDetail.tsx +2 -0
  110. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  111. package/src/ui/styled/CourseDetail.tsx +2 -0
  112. package/src/ui/styled/DownloadPage.tsx +263 -0
  113. package/src/ui/styled/EventConfirmation.tsx +10 -0
  114. package/src/ui/styled/EventDetail.tsx +10 -0
  115. package/src/ui/styled/FilmLibrary.tsx +22 -9
  116. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  117. package/src/ui/styled/ProductDetail.tsx +12 -0
  118. package/src/ui/styled/PwaRegistration.tsx +5 -1
  119. package/src/ui/styled/SignupForm.tsx +18 -3
  120. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  121. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  122. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  123. package/src/ui/styled/app/AppBar.tsx +298 -0
  124. package/src/ui/styled/app/AppHome.tsx +1139 -0
  125. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  126. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  127. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  128. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  129. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  130. package/src/ui/styled/app/index.ts +7 -0
  131. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  132. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  133. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  134. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  135. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  136. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  137. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  138. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  139. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  140. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  141. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  142. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  143. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  144. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  145. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  146. package/src/ui/styled/communities/index.ts +9 -0
  147. package/src/ui/styled/communities/shared.tsx +39 -11
  148. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  149. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  150. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  151. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  152. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  153. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  154. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  155. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  156. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  157. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  158. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  159. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  160. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  161. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  162. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  163. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  164. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  165. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  166. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  167. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  168. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  169. package/src/ui/styled/community/index.ts +37 -2
  170. package/src/ui/styled/forge-utilities.css +234 -0
  171. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  172. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  173. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  174. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  175. package/src/ui/styled/members/index.ts +8 -0
  176. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  177. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  178. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  179. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  180. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  181. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  182. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  183. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  184. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  185. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  186. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  187. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  188. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  189. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  190. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  191. package/src/ui/styled/portal/index.ts +13 -0
  192. package/src/ui/styled/portal/portalNav.ts +260 -0
  193. package/src/ui/styled/portal/shared.tsx +206 -0
  194. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  195. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  196. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  197. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  198. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  199. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  200. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  201. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  202. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  203. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  204. package/src/ui/styled/work/index.ts +1 -1
  205. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  206. package/src/utils/landing.ts +19 -6
  207. package/src/utils/metaEvents.ts +302 -0
@@ -0,0 +1,250 @@
1
+ import { useId, type CSSProperties } from "react";
2
+ import { BarChart3, Check } from "lucide-react";
3
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
4
+ import { useForgeLocale, useForgeT } from "../../../i18n";
5
+ import { pollOptionPercent, useCommunityPoll } from "../../headless/community/communityPoll";
6
+ import type { CommunityPost } from "../../../data/queries/useCommunity";
7
+
8
+ export interface CommunityPollCardProps {
9
+ /** The post the poll rides on. Its title is the question. */
10
+ post: Pick<CommunityPost, "id" | "spaceId" | "title" | "poll">;
11
+ /** Read-only, for a post still waiting for approval. */
12
+ disabled?: boolean;
13
+ }
14
+
15
+ const VOTE_ERROR_KEY = {
16
+ closed: "forge.community_poll_card.error_closed",
17
+ invalid: "forge.community_poll_card.error_invalid",
18
+ forbidden: "forge.community_poll_card.error_forbidden",
19
+ generic: "forge.community_poll_card.error_generic",
20
+ } as const;
21
+
22
+ /** A close time in the viewer's locale and time zone. */
23
+ function formatCloseTime(iso: string, locale: string): string {
24
+ const date = new Date(iso);
25
+ if (Number.isNaN(date.getTime())) return "";
26
+ return date.toLocaleString(locale === "de" ? "de-DE" : "en-US", { dateStyle: "medium", timeStyle: "short" });
27
+ }
28
+
29
+ /**
30
+ * A post's poll: pick one option (radio) or several (checkboxes), vote, change
31
+ * the vote or withdraw it. Counts show as bars once this viewer may see them;
32
+ * until then the card says when they will. A closed poll shows its final
33
+ * state and takes no votes. Built on `useCommunityPoll`.
34
+ */
35
+ export function CommunityPollCard({ post, disabled }: CommunityPollCardProps) {
36
+ const t = useThemeTokens();
37
+ const tr = useForgeT();
38
+ const locale = useForgeLocale();
39
+ const state = useCommunityPoll(post, { disabled });
40
+ const name = useId();
41
+ const poll = state.poll;
42
+ if (!poll) return null;
43
+
44
+ const border = `1px solid ${t.border}`;
45
+ const options = [...poll.options].sort((a, b) => a.position - b.position);
46
+ const showCounts = poll.resultsVisible && !state.choosing;
47
+ const question = post.title?.trim() || tr("forge.community_poll_card.untitled");
48
+
49
+ const ghost: CSSProperties = {
50
+ background: "none",
51
+ border,
52
+ borderRadius: t.cornerRadius,
53
+ padding: "6px 12px",
54
+ cursor: state.pending ? "default" : "pointer",
55
+ color: t.text,
56
+ fontSize: 13,
57
+ opacity: state.pending ? 0.6 : 1,
58
+ };
59
+ const primary: CSSProperties = {
60
+ background: t.primary,
61
+ color: t.textPrimary,
62
+ border: "none",
63
+ borderRadius: t.cornerRadius,
64
+ padding: "6px 16px",
65
+ fontWeight: 600,
66
+ fontSize: 13,
67
+ cursor: state.canSubmit ? "pointer" : "not-allowed",
68
+ opacity: state.canSubmit ? 1 : 0.6,
69
+ };
70
+
71
+ const statusLine: string[] = [];
72
+ if (poll.totalVoters !== null) {
73
+ statusLine.push(
74
+ poll.totalVoters === 1
75
+ ? tr("forge.community_poll_card.voters_one")
76
+ : tr("forge.community_poll_card.voters_other", { count: poll.totalVoters }),
77
+ );
78
+ }
79
+ if (poll.closed) statusLine.push(tr("forge.community_poll_card.closed"));
80
+ else if (poll.closesAt) {
81
+ statusLine.push(tr("forge.community_poll_card.closes_at", { date: formatCloseTime(poll.closesAt, locale) }));
82
+ }
83
+ if (poll.multipleChoice && state.choosing) statusLine.push(tr("forge.community_poll_card.pick_several"));
84
+
85
+ return (
86
+ <form
87
+ data-testid="community-poll"
88
+ onSubmit={(e) => {
89
+ e.preventDefault();
90
+ if (state.canSubmit) void state.submit();
91
+ }}
92
+ style={{ marginTop: 12 }}
93
+ >
94
+ <fieldset
95
+ disabled={!state.choosing || state.pending}
96
+ style={{ border, borderRadius: t.cornerRadius, padding: 12, margin: 0, minWidth: 0 }}
97
+ >
98
+ <legend
99
+ style={{
100
+ display: "inline-flex",
101
+ alignItems: "center",
102
+ gap: 6,
103
+ padding: "0 4px",
104
+ fontSize: 13,
105
+ fontWeight: 600,
106
+ color: t.text,
107
+ }}
108
+ >
109
+ <BarChart3 size={14} aria-hidden style={{ color: t.primary }} />
110
+ {/* The post shows the question as its title just above, so the
111
+ legend reads "Poll" on screen and names the question for a
112
+ screen reader. */}
113
+ <span aria-hidden>{tr("forge.community_poll_card.label")}</span>
114
+ <span className="fg:sr-only">{question}</span>
115
+ </legend>
116
+
117
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
118
+ {options.map((option) => {
119
+ const mine = poll.myVotes.includes(option.id);
120
+ const percent = pollOptionPercent(option.votes, poll.totalVoters);
121
+ const inputId = `${name}-${option.id}`;
122
+
123
+ if (state.choosing) {
124
+ const selected = state.isSelected(option.id);
125
+ return (
126
+ <label
127
+ key={option.id}
128
+ htmlFor={inputId}
129
+ data-testid="community-poll-option"
130
+ style={{
131
+ display: "flex",
132
+ alignItems: "center",
133
+ gap: 8,
134
+ padding: "8px 10px",
135
+ border: `1px solid ${selected ? t.primary : t.border}`,
136
+ borderRadius: t.cornerRadius,
137
+ background: selected ? `${t.primary}0d` : t.background,
138
+ color: t.text,
139
+ fontSize: 14,
140
+ cursor: "pointer",
141
+ }}
142
+ >
143
+ <input
144
+ id={inputId}
145
+ type={poll.multipleChoice ? "checkbox" : "radio"}
146
+ name={name}
147
+ value={option.id}
148
+ checked={selected}
149
+ onChange={() => state.toggle(option.id)}
150
+ style={{ accentColor: t.primary, margin: 0 }}
151
+ />
152
+ <span style={{ flex: 1, minWidth: 0, overflowWrap: "anywhere" }}>{option.label}</span>
153
+ {poll.resultsVisible && percent !== null && (
154
+ <span style={{ fontSize: 12, color: t.muted }}>{`${percent}%`}</span>
155
+ )}
156
+ </label>
157
+ );
158
+ }
159
+
160
+ return (
161
+ <div
162
+ key={option.id}
163
+ data-testid="community-poll-result"
164
+ style={{
165
+ position: "relative",
166
+ overflow: "hidden",
167
+ padding: "8px 10px",
168
+ border: `1px solid ${mine ? t.primary : t.border}`,
169
+ borderRadius: t.cornerRadius,
170
+ background: t.background,
171
+ color: t.text,
172
+ fontSize: 14,
173
+ }}
174
+ >
175
+ {showCounts && percent !== null && (
176
+ <span
177
+ aria-hidden
178
+ data-testid="community-poll-bar"
179
+ style={{
180
+ position: "absolute",
181
+ inset: 0,
182
+ width: `${percent}%`,
183
+ background: `${t.primary}${mine ? "33" : "1a"}`,
184
+ }}
185
+ />
186
+ )}
187
+ <span style={{ position: "relative", display: "flex", alignItems: "center", gap: 8 }}>
188
+ {mine && <Check size={14} style={{ color: t.primary, flexShrink: 0 }} aria-hidden />}
189
+ <span style={{ flex: 1, minWidth: 0, overflowWrap: "anywhere", fontWeight: mine ? 600 : 400 }}>
190
+ {option.label}
191
+ {mine && <span className="fg:sr-only">{` ${tr("forge.community_poll_card.your_vote")}`}</span>}
192
+ </span>
193
+ {showCounts && percent !== null && (
194
+ <span style={{ fontSize: 13, fontWeight: 600 }}>{`${percent}%`}</span>
195
+ )}
196
+ </span>
197
+ </div>
198
+ );
199
+ })}
200
+ </div>
201
+ </fieldset>
202
+
203
+ {!poll.resultsVisible && (
204
+ <p data-testid="community-poll-hidden" style={{ margin: "8px 0 0", fontSize: 12, color: t.muted }}>
205
+ {state.hiddenReason === "after_close"
206
+ ? tr("forge.community_poll_card.results_after_close")
207
+ : tr("forge.community_poll_card.results_after_vote")}
208
+ </p>
209
+ )}
210
+
211
+ {state.error && (
212
+ <p role="alert" style={{ margin: "8px 0 0", fontSize: 12, color: "#dc2626" }}>
213
+ {tr(VOTE_ERROR_KEY[state.error])}
214
+ </p>
215
+ )}
216
+
217
+ <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap", marginTop: 8 }}>
218
+ <span data-testid="community-poll-status" style={{ flex: 1, minWidth: 140, fontSize: 12, color: t.muted }}>
219
+ {statusLine.join(" ยท ")}
220
+ </span>
221
+ {state.choosing && (
222
+ <>
223
+ {state.changing && (
224
+ <button type="button" onClick={state.cancelChange} disabled={state.pending} style={ghost}>
225
+ {tr("forge.community_poll_card.cancel")}
226
+ </button>
227
+ )}
228
+ <button type="submit" disabled={!state.canSubmit} style={primary}>
229
+ {state.pending
230
+ ? tr("forge.community_poll_card.sending")
231
+ : state.changing
232
+ ? tr("forge.community_poll_card.update_vote")
233
+ : tr("forge.community_poll_card.vote")}
234
+ </button>
235
+ </>
236
+ )}
237
+ {!state.choosing && state.canVote && state.hasVoted && (
238
+ <>
239
+ <button type="button" onClick={state.startChange} disabled={state.pending} style={ghost}>
240
+ {tr("forge.community_poll_card.change_vote")}
241
+ </button>
242
+ <button type="button" onClick={() => void state.withdraw()} disabled={state.pending} style={ghost}>
243
+ {tr("forge.community_poll_card.withdraw")}
244
+ </button>
245
+ </>
246
+ )}
247
+ </div>
248
+ </form>
249
+ );
250
+ }
@@ -1,10 +1,16 @@
1
- import { Lock, MessageCircle, Pin } from "lucide-react";
1
+ import { BarChart3, Link2, Lock, MessageCircle, Pin } from "lucide-react";
2
+ import { useForgeT } from "../../../i18n";
2
3
  import { useThemeTokens } from "../../theme/ForgeThemeProvider";
3
- import { communityMemberName, type CommunityPost } from "../../../data/queries/useCommunity";
4
+ import { communityMemberName, isCommunityContentPending, type CommunityPost } from "../../../data/queries/useCommunity";
4
5
  import { CommunityRichText } from "./CommunityRichText";
5
6
  import { CommunityMediaGallery } from "./CommunityMediaGallery";
7
+ import { CommunityPollCard } from "./CommunityPollCard";
8
+ import { CommunityLinkPreview } from "./CommunityLinkPreview";
6
9
  import { CommunityReactionBar } from "./CommunityReactionBar";
10
+ import { CommunityModeratorMenu } from "./CommunityModeratorMenu";
11
+ import { CommunityPendingBadge, CommunityRemovedNotice } from "./CommunityModerationState";
7
12
  import { LevelBadge, MemberAvatar } from "./MemberAvatar";
13
+ import { CommunityAgentAvatar, CommunityAgentName, CommunityAgentThinking } from "./CommunityAgent";
8
14
  import { timeAgo } from "./util";
9
15
 
10
16
  export interface CommunityPostCardProps {
@@ -14,15 +20,41 @@ export interface CommunityPostCardProps {
14
20
  onOpen?: (post: CommunityPost) => void;
15
21
  onOpenProfile?: (accountId: string) => void;
16
22
  onReact?: (emoji: string) => void;
23
+ /**
24
+ * The community AI is writing an answer under this post: its name, for the
25
+ * "thinking" line. Leave unset when it is not.
26
+ */
27
+ agentThinking?: { name?: string | null } | null;
17
28
  }
18
29
 
19
- /** One post in a space โ€” card (feed spaces) or compact row (forum spaces). */
20
- export function CommunityPostCard({ post, variant, onOpen, onOpenProfile, onReact }: CommunityPostCardProps) {
30
+ /**
31
+ * One post in a space: a card (feed spaces) or a compact row (forum spaces).
32
+ * The author sees "Waiting for approval" on their own held post, which cannot
33
+ * be replied to or reacted to yet. A moderator gets the actions menu, and a
34
+ * post they just removed collapses to an undo.
35
+ */
36
+ export function CommunityPostCard({ post, variant, onOpen, onOpenProfile, onReact, agentThinking }: CommunityPostCardProps) {
21
37
  const t = useThemeTokens();
38
+ const tr = useForgeT();
22
39
  const border = `1px solid ${t.border}`;
23
40
  const author = post.author;
41
+ // The community AI wrote it: its name and badge, never the "Member" fallback.
42
+ const agent = post.agentAuthor ?? null;
43
+ const pending = isCommunityContentPending(post);
24
44
 
25
- const meta = (
45
+ if (post.archivedAt) return <CommunityRemovedNotice target={{ type: "post", post }} />;
46
+
47
+ const moderatorMenu = <CommunityModeratorMenu target={{ type: "post", post }} />;
48
+
49
+ const meta = agent ? (
50
+ <div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
51
+ <CommunityAgentAvatar agent={agent} size={variant === "feed" ? 36 : 28} />
52
+ <div style={{ minWidth: 0 }}>
53
+ <CommunityAgentName agent={agent} />
54
+ <div style={{ fontSize: 12, color: t.muted }}>{timeAgo(post.createdAt)}</div>
55
+ </div>
56
+ </div>
57
+ ) : (
26
58
  <div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
27
59
  <MemberAvatar member={author} size={variant === "feed" ? 36 : 28} onClick={() => author && onOpenProfile?.(author.accountId)} />
28
60
  <div style={{ minWidth: 0 }}>
@@ -58,8 +90,26 @@ export function CommunityPostCard({ post, variant, onOpen, onOpenProfile, onReac
58
90
  <span style={{ display: "inline-flex", gap: 6, alignItems: "center" }}>
59
91
  {post.isPinned && <Pin size={14} style={{ color: t.primary }} aria-label="Pinned" />}
60
92
  {post.isLocked && <Lock size={14} style={{ color: t.muted }} aria-label="Locked" />}
93
+ {/* The compact forum row has no room for the poll itself: it opens in the thread. */}
94
+ {variant === "forum" && post.poll && (
95
+ <BarChart3 size={14} style={{ color: t.primary }} aria-label={tr("forge.community_post_card.has_poll")} />
96
+ )}
97
+ {/* Nor for a link card: a small mark says there is one to open. */}
98
+ {variant === "forum" && post.linkPreview && (
99
+ <Link2
100
+ size={14}
101
+ data-testid="community-post-link-icon"
102
+ style={{ color: t.muted }}
103
+ aria-label={tr("forge.community_post_card.has_link")}
104
+ />
105
+ )}
61
106
  </span>
62
107
  );
108
+ const statusBadge = pending ? (
109
+ <CommunityPendingBadge />
110
+ ) : post.status === "rejected" ? (
111
+ <CommunityPendingBadge status="rejected" />
112
+ ) : null;
63
113
 
64
114
  if (variant === "forum") {
65
115
  return (
@@ -96,17 +146,28 @@ export function CommunityPostCard({ post, variant, onOpen, onOpenProfile, onReac
96
146
  {post.title || post.bodyText?.slice(0, 80) || "Untitled"}
97
147
  </span>
98
148
  </button>
99
- <div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 4, fontSize: 12, color: t.muted }}>
100
- <span>{communityMemberName(author)}</span>
149
+ <div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 4, fontSize: 12, color: t.muted, flexWrap: "wrap" }}>
150
+ {agent ? <CommunityAgentName agent={agent} fontSize={12} fontWeight={500} /> : <span>{communityMemberName(author)}</span>}
101
151
  <span>ยท</span>
102
152
  <span>{timeAgo(post.lastActivityAt ?? post.createdAt)}</span>
153
+ {statusBadge}
103
154
  </div>
155
+ {agentThinking && (
156
+ <div style={{ marginTop: 4 }}>
157
+ <CommunityAgentThinking name={agentThinking.name} />
158
+ </div>
159
+ )}
104
160
  </div>
105
161
  <div style={{ display: "flex", alignItems: "center", gap: 12, fontSize: 13, color: t.muted, flexShrink: 0 }}>
106
- <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
107
- <MessageCircle size={14} /> {post.replyCount}
108
- </span>
109
- <span>{post.reactionCount > 0 ? `${post.reactionCount} ๐Ÿ‘` : ""}</span>
162
+ {!pending && (
163
+ <>
164
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
165
+ <MessageCircle size={14} /> {post.replyCount}
166
+ </span>
167
+ <span>{post.reactionCount > 0 ? `${post.reactionCount} ๐Ÿ‘` : ""}</span>
168
+ </>
169
+ )}
170
+ {moderatorMenu}
110
171
  </div>
111
172
  </div>
112
173
  );
@@ -116,7 +177,11 @@ export function CommunityPostCard({ post, variant, onOpen, onOpenProfile, onReac
116
177
  <article style={{ border, borderRadius: t.cornerRadius, background: t.surface, padding: 16 }}>
117
178
  <header style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
118
179
  {meta}
119
- {badges}
180
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 8, flexShrink: 0 }}>
181
+ {statusBadge}
182
+ {badges}
183
+ {moderatorMenu}
184
+ </span>
120
185
  </header>
121
186
 
122
187
  {post.title && (
@@ -135,31 +200,42 @@ export function CommunityPostCard({ post, variant, onOpen, onOpenProfile, onReac
135
200
  )}
136
201
 
137
202
  <div style={{ marginTop: post.title ? 0 : 10 }}>
138
- <CommunityRichText body={post.body} clampLines={8} />
203
+ <CommunityRichText body={post.body} clampLines={8} linkify={!!agent} />
139
204
  </div>
140
205
  <CommunityMediaGallery media={post.media ?? []} />
206
+ <CommunityLinkPreview preview={post.linkPreview} />
207
+ {/* A held post shows its poll but takes no votes until it is approved. */}
208
+ {post.poll && <CommunityPollCard post={post} disabled={pending} />}
141
209
 
142
- <footer style={{ marginTop: 12 }}>
143
- <CommunityReactionBar reactions={post.reactions ?? []} onToggle={(emoji) => onReact?.(emoji)}>
144
- <button
145
- onClick={() => onOpen?.(post)}
146
- style={{
147
- display: "inline-flex",
148
- alignItems: "center",
149
- gap: 4,
150
- background: "none",
151
- border: "none",
152
- cursor: "pointer",
153
- color: t.muted,
154
- fontSize: 13,
155
- padding: "2px 4px",
156
- }}
157
- >
158
- <MessageCircle size={14} />
159
- {post.replyCount > 0 ? `${post.replyCount} ${post.replyCount === 1 ? "reply" : "replies"}` : "Reply"}
160
- </button>
161
- </CommunityReactionBar>
162
- </footer>
210
+ {/* A held post takes no replies or reactions until it is approved. */}
211
+ {!pending && (
212
+ <footer style={{ marginTop: 12 }}>
213
+ <CommunityReactionBar reactions={post.reactions ?? []} onToggle={(emoji) => onReact?.(emoji)}>
214
+ <button
215
+ onClick={() => onOpen?.(post)}
216
+ style={{
217
+ display: "inline-flex",
218
+ alignItems: "center",
219
+ gap: 4,
220
+ background: "none",
221
+ border: "none",
222
+ cursor: "pointer",
223
+ color: t.muted,
224
+ fontSize: 13,
225
+ padding: "2px 4px",
226
+ }}
227
+ >
228
+ <MessageCircle size={14} />
229
+ {post.replyCount > 0 ? `${post.replyCount} ${post.replyCount === 1 ? "reply" : "replies"}` : "Reply"}
230
+ </button>
231
+ </CommunityReactionBar>
232
+ </footer>
233
+ )}
234
+ {agentThinking && (
235
+ <div style={{ marginTop: 8 }}>
236
+ <CommunityAgentThinking name={agentThinking.name} />
237
+ </div>
238
+ )}
163
239
  </article>
164
240
  );
165
241
  }