@tribe-nest/forge 3.59.0 → 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/package.json +2 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CartContext.tsx +34 -1
  4. package/src/contexts/CommunityContext.tsx +192 -0
  5. package/src/contexts/PublicAuthContext.tsx +15 -3
  6. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  7. package/src/data/queries/useAccount.ts +33 -2
  8. package/src/data/queries/useAnalytics.ts +6 -6
  9. package/src/data/queries/useCallChat.ts +2 -2
  10. package/src/data/queries/useChat.ts +52 -40
  11. package/src/data/queries/useCheckouts.ts +2 -1
  12. package/src/data/queries/useCoachingAvailability.ts +10 -1
  13. package/src/data/queries/useCommunities.ts +345 -0
  14. package/src/data/queries/useCommunity.ts +450 -102
  15. package/src/data/queries/useCommunityAgent.ts +247 -0
  16. package/src/data/queries/useCommunityCalendar.ts +150 -0
  17. package/src/data/queries/useCommunityClassroom.ts +75 -0
  18. package/src/data/queries/useCommunityMembers.ts +108 -0
  19. package/src/data/queries/useCommunityModeration.ts +327 -0
  20. package/src/data/queries/useCommunityWiki.ts +137 -0
  21. package/src/data/queries/useCourses.ts +2 -0
  22. package/src/data/queries/useEmailSignIn.ts +79 -0
  23. package/src/data/queries/useEvents.ts +6 -1
  24. package/src/data/queries/useForms.ts +5 -1
  25. package/src/data/queries/useLeadMagnet.ts +6 -2
  26. package/src/data/queries/useMembership.ts +32 -7
  27. package/src/data/queries/useMembershipCalls.ts +116 -7
  28. package/src/data/queries/useMyBookings.ts +24 -1
  29. package/src/data/queries/useMyTickets.ts +24 -1
  30. package/src/data/queries/useNotifications.ts +6 -2
  31. package/src/data/queries/useOrders.ts +2 -1
  32. package/src/data/queries/usePWA.ts +111 -35
  33. package/src/data/queries/usePostCollections.ts +28 -16
  34. package/src/data/queries/usePosts.ts +16 -14
  35. package/src/data/queries/useSiteApp.ts +130 -0
  36. package/src/data/queries/useSubscriptions.ts +28 -9
  37. package/src/i18n/de.json +891 -2
  38. package/src/i18n/en.json +891 -2
  39. package/src/index.ts +36 -7
  40. package/src/runtime/pages.spec.ts +36 -0
  41. package/src/runtime/pages.ts +52 -27
  42. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  43. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  44. package/src/types/models.ts +11 -0
  45. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  46. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  47. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  48. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  49. package/src/ui/analytics/useMetaEvents.ts +78 -0
  50. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  51. package/src/ui/format/membershipGate.ts +19 -15
  52. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  53. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  54. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  55. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  56. package/src/ui/headless/_tests/communityHarness.tsx +212 -0
  57. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  58. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  59. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  60. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  61. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  62. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  63. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  64. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  65. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  66. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
  67. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
  68. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  69. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
  70. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  71. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  72. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  73. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  74. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  75. package/src/ui/headless/auth/useSignupForm.ts +30 -3
  76. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  77. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  78. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  79. package/src/ui/headless/communities/index.ts +96 -0
  80. package/src/ui/headless/communities/paths.ts +36 -0
  81. package/src/ui/headless/communities/useCommunity.ts +140 -0
  82. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  83. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  84. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  85. package/src/ui/headless/communities/useCommunityHome.ts +91 -0
  86. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  87. package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
  88. package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
  89. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  90. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  91. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  92. package/src/ui/headless/community/communityPoll.ts +297 -0
  93. package/src/ui/headless/community/countries.ts +123 -0
  94. package/src/ui/headless/community/index.ts +81 -1
  95. package/src/ui/headless/community/linkPreview.ts +71 -0
  96. package/src/ui/headless/community/richLexical.ts +27 -2
  97. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  98. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  99. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  100. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  101. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  102. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  103. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  104. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  105. package/src/ui/headless/downloads/index.ts +8 -0
  106. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  107. package/src/ui/headless/index.ts +21 -4
  108. package/src/ui/headless/memberHome/index.ts +11 -1
  109. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  110. package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
  111. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  112. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  113. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  114. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  115. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  116. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  117. package/src/ui/headless/portal/index.ts +57 -0
  118. package/src/ui/headless/portal/usePortal.ts +274 -0
  119. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  120. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  121. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  122. package/src/ui/headless/posts/upsell.ts +54 -8
  123. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  124. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  125. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  126. package/src/ui/headless/posts/usePostItem.ts +6 -5
  127. package/src/ui/headless/siteApp/appBar.ts +102 -0
  128. package/src/ui/headless/siteApp/appHome.ts +344 -0
  129. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  130. package/src/ui/headless/siteApp/index.ts +58 -0
  131. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  132. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  133. package/src/ui/headless/work/index.ts +2 -0
  134. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  135. package/src/ui/index.ts +28 -12
  136. package/src/ui/styled/AccountDashboard.tsx +754 -602
  137. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  138. package/src/ui/styled/ChatRoom.tsx +126 -16
  139. package/src/ui/styled/Checkout.tsx +8 -0
  140. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  141. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  142. package/src/ui/styled/CoachingDetail.tsx +2 -0
  143. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  144. package/src/ui/styled/CourseDetail.tsx +2 -0
  145. package/src/ui/styled/DownloadPage.tsx +263 -0
  146. package/src/ui/styled/EventConfirmation.tsx +10 -0
  147. package/src/ui/styled/EventDetail.tsx +10 -0
  148. package/src/ui/styled/FilmLibrary.tsx +22 -9
  149. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  150. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  151. package/src/ui/styled/MembershipTiers.tsx +49 -11
  152. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  153. package/src/ui/styled/Paywall.tsx +1 -1
  154. package/src/ui/styled/ProductDetail.tsx +12 -0
  155. package/src/ui/styled/PwaRegistration.tsx +5 -1
  156. package/src/ui/styled/SignupForm.tsx +18 -3
  157. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
  158. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  159. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  160. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  161. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  162. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  163. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  164. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  165. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  166. package/src/ui/styled/app/AppBar.tsx +298 -0
  167. package/src/ui/styled/app/AppHome.tsx +1139 -0
  168. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  169. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  170. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  171. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  172. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  173. package/src/ui/styled/app/index.ts +7 -0
  174. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  175. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  176. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  177. package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
  178. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  179. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  180. package/src/ui/styled/communities/CommunityHome.tsx +298 -0
  181. package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
  182. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
  183. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  184. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  185. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  186. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  187. package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
  188. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  189. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  190. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  191. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  192. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  193. package/src/ui/styled/communities/index.ts +19 -0
  194. package/src/ui/styled/communities/shared.tsx +177 -0
  195. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  196. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  197. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  198. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  199. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  200. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  201. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  202. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  203. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  204. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  205. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  206. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  207. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  208. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  209. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  210. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  211. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  212. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  213. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  214. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  215. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  216. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  217. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  218. package/src/ui/styled/community/index.ts +37 -2
  219. package/src/ui/styled/forge-utilities.css +253 -0
  220. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  221. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  222. package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
  223. package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
  224. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  225. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  226. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  227. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  228. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  229. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  230. package/src/ui/styled/members/index.ts +8 -0
  231. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  232. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  233. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  234. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  235. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  236. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  237. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  238. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  239. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  240. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  241. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  242. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  243. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  244. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  245. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  246. package/src/ui/styled/portal/index.ts +13 -0
  247. package/src/ui/styled/portal/portalNav.ts +260 -0
  248. package/src/ui/styled/portal/shared.tsx +206 -0
  249. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  250. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  251. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  252. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  253. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  254. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  255. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  256. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  257. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  258. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  259. package/src/ui/styled/work/index.ts +1 -1
  260. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  261. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  262. package/src/utils/landing.ts +19 -6
  263. package/src/utils/metaEvents.ts +302 -0
  264. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  265. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  266. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
@@ -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
+ }
@@ -0,0 +1,171 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
3
+ import {
4
+ globeAnglesFor,
5
+ memberGlobeMarkers,
6
+ type MemberGlobePoint,
7
+ } from "../../headless/community/useCommunityMembers";
8
+
9
+ export interface CommunityMemberGlobeProps {
10
+ points: MemberGlobePoint[];
11
+ /** Called when WebGL is unavailable, so the caller can show the country list instead. */
12
+ onUnsupported?: () => void;
13
+ /** Accessible name for the drawing. */
14
+ label: string;
15
+ className?: string;
16
+ }
17
+
18
+ /** A theme colour as cobe's 0..1 RGB triple. Falls back to mid grey. */
19
+ function rgb01(color: string): [number, number, number] {
20
+ const hex = color.trim().replace(/^#/, "");
21
+ const full = hex.length === 3 ? hex.replace(/./g, (c) => c + c) : hex.slice(0, 6);
22
+ const n = Number.parseInt(full, 16);
23
+ if (!/^[0-9a-f]{6}$/i.test(full) || Number.isNaN(n)) return [0.5, 0.5, 0.5];
24
+ return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
25
+ }
26
+
27
+ /** Perceived lightness 0..1, to decide whether the globe sits on a dark page. */
28
+ function lightness([r, g, b]: [number, number, number]): number {
29
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
30
+ }
31
+
32
+ type CobeGlobe = { update: (state: Record<string, unknown>) => void; destroy: () => void };
33
+
34
+ /**
35
+ * A slowly turning dotted globe with one dot per country members are in, sized
36
+ * by how many. Country level only: members choose a country, never a place.
37
+ * Drag to turn it. It starts facing the biggest country, stops turning for
38
+ * visitors who asked for reduced motion, and stops drawing while out of view.
39
+ * Colours come from the theme.
40
+ *
41
+ * `cobe` is loaded with a dynamic `import()` from the effect, so nothing of
42
+ * WebGL is on the server render path. cobe v2 has no render loop of its own;
43
+ * this drives it with `update()`, as the admin analytics globe does.
44
+ */
45
+ export function CommunityMemberGlobe({ points, onUnsupported, label, className }: CommunityMemberGlobeProps) {
46
+ const t = useThemeTokens();
47
+ const canvasRef = useRef<HTMLCanvasElement>(null);
48
+ const globeRef = useRef<CobeGlobe | null>(null);
49
+ const drag = useRef<{ startX: number; startPhi: number } | null>(null);
50
+ const phiRef = useRef(0);
51
+ const thetaRef = useRef(0.3);
52
+ const pointsRef = useRef(points);
53
+ pointsRef.current = points;
54
+ const [ready, setReady] = useState(false);
55
+
56
+ useEffect(() => {
57
+ const canvas = canvasRef.current;
58
+ if (!canvas) return;
59
+ let cancelled = false;
60
+ let cleanup: (() => void) | null = null;
61
+
62
+ (async () => {
63
+ let createGlobe: (canvas: HTMLCanvasElement, opts: Record<string, unknown>) => CobeGlobe;
64
+ try {
65
+ createGlobe = (await import("cobe")).default as unknown as typeof createGlobe;
66
+ } catch {
67
+ if (!cancelled) onUnsupported?.();
68
+ return;
69
+ }
70
+ if (cancelled) return;
71
+
72
+ const background = rgb01(t.background);
73
+ const dark = lightness(background) < 0.5;
74
+ const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
75
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
76
+ let size = canvas.offsetWidth || 400;
77
+
78
+ const biggest = pointsRef.current[0];
79
+ if (biggest) {
80
+ const [phi, theta] = globeAnglesFor(biggest.latitude, biggest.longitude);
81
+ phiRef.current = phi;
82
+ thetaRef.current = Math.max(-0.6, Math.min(0.6, theta));
83
+ }
84
+
85
+ let globe: CobeGlobe;
86
+ try {
87
+ globe = createGlobe(canvas, {
88
+ devicePixelRatio: dpr,
89
+ width: size * dpr,
90
+ height: size * dpr,
91
+ phi: phiRef.current,
92
+ theta: thetaRef.current,
93
+ dark: dark ? 1 : 0,
94
+ diffuse: 1.2,
95
+ mapSamples: 16000,
96
+ mapBrightness: dark ? 6 : 1.8,
97
+ baseColor: dark ? [0.3, 0.3, 0.3] : [1, 1, 1],
98
+ markerColor: rgb01(t.primary),
99
+ glowColor: background,
100
+ markers: memberGlobeMarkers(pointsRef.current),
101
+ markerElevation: 0.02,
102
+ });
103
+ } catch {
104
+ onUnsupported?.();
105
+ return;
106
+ }
107
+ globeRef.current = globe;
108
+ setReady(true);
109
+
110
+ let visible = true;
111
+ let frame = 0;
112
+ const tick = () => {
113
+ if (visible) {
114
+ if (!drag.current && !reduceMotion) phiRef.current += 0.0025;
115
+ globe.update({ phi: phiRef.current, theta: thetaRef.current, width: size * dpr, height: size * dpr });
116
+ }
117
+ frame = requestAnimationFrame(tick);
118
+ };
119
+ frame = requestAnimationFrame(tick);
120
+
121
+ const io =
122
+ typeof IntersectionObserver !== "undefined"
123
+ ? new IntersectionObserver(([entry]) => (visible = entry.isIntersecting))
124
+ : null;
125
+ io?.observe(canvas);
126
+ const ro =
127
+ typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => (size = canvas.offsetWidth || size)) : null;
128
+ ro?.observe(canvas);
129
+
130
+ cleanup = () => {
131
+ cancelAnimationFrame(frame);
132
+ io?.disconnect();
133
+ ro?.disconnect();
134
+ globe.destroy();
135
+ globeRef.current = null;
136
+ };
137
+ })();
138
+
139
+ return () => {
140
+ cancelled = true;
141
+ cleanup?.();
142
+ };
143
+ // Built once per theme; markers are pushed in by the effect below.
144
+ // eslint-disable-next-line react-hooks/exhaustive-deps
145
+ }, [t.background, t.primary]);
146
+
147
+ useEffect(() => {
148
+ globeRef.current?.update({ markers: memberGlobeMarkers(points) });
149
+ }, [points]);
150
+
151
+ return (
152
+ <canvas
153
+ ref={canvasRef}
154
+ role="img"
155
+ aria-label={label}
156
+ data-testid="community-member-globe"
157
+ className={className ?? "fg:aspect-square fg:w-full fg:max-w-[420px] fg:cursor-grab fg:touch-none"}
158
+ style={{ opacity: ready ? 1 : 0, transition: "opacity 0.7s" }}
159
+ onPointerDown={(e) => {
160
+ drag.current = { startX: e.clientX, startPhi: phiRef.current };
161
+ e.currentTarget.setPointerCapture?.(e.pointerId);
162
+ }}
163
+ onPointerMove={(e) => {
164
+ if (!drag.current) return;
165
+ phiRef.current = drag.current.startPhi + (e.clientX - drag.current.startX) / 150;
166
+ }}
167
+ onPointerUp={() => (drag.current = null)}
168
+ onPointerCancel={() => (drag.current = null)}
169
+ />
170
+ );
171
+ }
@@ -1,5 +1,8 @@
1
- import { useRef, useState } from "react";
1
+ import { useMemo, useRef, useState } from "react";
2
2
  import { useThemeTokens } from "../../theme/ForgeThemeProvider";
3
+ import { useForgeLocale, useForgeT } from "../../../i18n";
4
+ import { countryOptions } from "../../headless/community/countries";
5
+ import { CallHelpHint } from "../../media/CallHelpHint";
3
6
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
4
7
  import {
5
8
  communityMemberName,
@@ -29,8 +32,9 @@ export interface CommunityMemberProfileProps {
29
32
  onBack?: () => void;
30
33
  }
31
34
 
32
- /** Member profile: avatar, bio, points/level, recent activity — plus settings
33
- * (display name, bio, avatar, digest cadence) on your own profile. */
35
+ /** Member profile: avatar, bio, points/level, recent activity. On your own
36
+ * profile, settings too: display name, bio, avatar, directory and map
37
+ * visibility, country and city, and digest cadence. */
34
38
  export function CommunityMemberProfile({ accountId, onOpenPost, onBack }: CommunityMemberProfileProps) {
35
39
  const t = useThemeTokens();
36
40
  const { user } = usePublicAuth();
@@ -163,8 +167,13 @@ function ProfileSettings({ profile }: { profile: MemberProfileData }) {
163
167
  const upload = useCommunityAttachmentUpload();
164
168
  const fileInputRef = useRef<HTMLInputElement | null>(null);
165
169
 
170
+ const tr = useForgeT();
171
+ const locale = useForgeLocale();
172
+ const countries = useMemo(() => countryOptions(locale), [locale]);
166
173
  const [displayName, setDisplayName] = useState(profile.displayName ?? "");
167
174
  const [bio, setBio] = useState(profile.bio ?? "");
175
+ const [city, setCity] = useState(profile.city ?? "");
176
+ const [countryCode, setCountryCode] = useState(profile.countryCode ?? "");
168
177
  const [saved, setSaved] = useState(false);
169
178
 
170
179
  const border = `1px solid ${t.border}`;
@@ -180,12 +189,21 @@ function ProfileSettings({ profile }: { profile: MemberProfileData }) {
180
189
  fontSize: 14,
181
190
  };
182
191
 
183
- const save = (extra: Partial<{ avatarUrl: string | null; digestCadence: CommunityDigestCadence }> = {}) => {
192
+ const save = (
193
+ extra: Partial<{
194
+ avatarUrl: string | null;
195
+ digestCadence: CommunityDigestCadence;
196
+ showInDirectory: boolean;
197
+ showOnMap: boolean;
198
+ }> = {},
199
+ ) => {
184
200
  setSaved(false);
185
201
  update.mutate(
186
202
  {
187
203
  displayName: displayName.trim() || null,
188
204
  bio: bio.trim() || null,
205
+ city: city.trim() || null,
206
+ countryCode: countryCode || null,
189
207
  ...extra,
190
208
  },
191
209
  { onSuccess: () => setSaved(true) },
@@ -242,6 +260,59 @@ function ProfileSettings({ profile }: { profile: MemberProfileData }) {
242
260
  {upload.error && <span style={{ fontSize: 12, color: "#dc2626" }}>{upload.error}</span>}
243
261
  </div>
244
262
 
263
+ <fieldset
264
+ data-testid="community-profile-directory-settings"
265
+ style={{ border: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 10 }}
266
+ >
267
+ <legend style={{ fontSize: 13, color: t.muted, padding: 0, marginBottom: 4 }}>
268
+ {tr("forge.community_member_profile.directory_legend")}
269
+ </legend>
270
+ <SettingSwitch
271
+ label={tr("forge.community_member_profile.show_in_directory")}
272
+ checked={profile.showInDirectory !== false}
273
+ disabled={update.isPending}
274
+ onChange={(value) => save({ showInDirectory: value })}
275
+ help={
276
+ <CallHelpHint label={tr("forge.community_member_profile.show_in_directory_help_label")}>
277
+ {tr("forge.community_member_profile.show_in_directory_help")}
278
+ </CallHelpHint>
279
+ }
280
+ />
281
+ <SettingSwitch
282
+ label={tr("forge.community_member_profile.show_on_map")}
283
+ checked={profile.showOnMap === true}
284
+ disabled={update.isPending}
285
+ onChange={(value) => save({ showOnMap: value })}
286
+ help={
287
+ <CallHelpHint label={tr("forge.community_member_profile.show_on_map_help_label")}>
288
+ {tr("forge.community_member_profile.show_on_map_help")}
289
+ </CallHelpHint>
290
+ }
291
+ />
292
+ <div className="fg:flex fg:flex-col fg:gap-3 fg:sm:flex-row">
293
+ <label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13, color: t.muted, flex: 1 }}>
294
+ {tr("forge.community_member_profile.country")}
295
+ <select value={countryCode} onChange={(e) => setCountryCode(e.target.value)} style={inputStyle}>
296
+ <option value="">{tr("forge.community_member_profile.country_none")}</option>
297
+ {countries.map((country) => (
298
+ <option key={country.code} value={country.code}>
299
+ {country.name}
300
+ </option>
301
+ ))}
302
+ </select>
303
+ </label>
304
+ <label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13, color: t.muted, flex: 1 }}>
305
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
306
+ {tr("forge.community_member_profile.city")}
307
+ <CallHelpHint label={tr("forge.community_member_profile.city_help_label")}>
308
+ {tr("forge.community_member_profile.city_help")}
309
+ </CallHelpHint>
310
+ </span>
311
+ <input value={city} onChange={(e) => setCity(e.target.value)} maxLength={80} style={inputStyle} />
312
+ </label>
313
+ </div>
314
+ </fieldset>
315
+
245
316
  <fieldset style={{ border: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 6 }}>
246
317
  <legend style={{ fontSize: 13, color: t.muted, padding: 0, marginBottom: 4 }}>Email digest</legend>
247
318
  {DIGEST_OPTIONS.map((opt) => (
@@ -279,7 +350,69 @@ function ProfileSettings({ profile }: { profile: MemberProfileData }) {
279
350
  {update.isPending ? "Saving…" : "Save"}
280
351
  </button>
281
352
  {saved && !update.isPending && <span style={{ fontSize: 13, color: t.muted }}>Saved ✓</span>}
353
+ {update.isError && (
354
+ <span role="alert" style={{ fontSize: 13, color: t.text, fontWeight: 600 }}>
355
+ {tr("forge.community_member_profile.save_error")}
356
+ </span>
357
+ )}
282
358
  </div>
283
359
  </div>
284
360
  );
285
361
  }
362
+
363
+ /** An on/off setting as an accessible switch, with its explanation behind a `?`. */
364
+ function SettingSwitch({
365
+ label,
366
+ checked,
367
+ disabled,
368
+ onChange,
369
+ help,
370
+ }: {
371
+ label: string;
372
+ checked: boolean;
373
+ disabled?: boolean;
374
+ onChange: (value: boolean) => void;
375
+ help?: React.ReactNode;
376
+ }) {
377
+ const t = useThemeTokens();
378
+ return (
379
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
380
+ <button
381
+ type="button"
382
+ role="switch"
383
+ aria-checked={checked}
384
+ aria-label={label}
385
+ disabled={disabled}
386
+ onClick={() => onChange(!checked)}
387
+ style={{
388
+ position: "relative",
389
+ width: 38,
390
+ height: 22,
391
+ flexShrink: 0,
392
+ borderRadius: 999,
393
+ border: "none",
394
+ padding: 0,
395
+ cursor: disabled ? "wait" : "pointer",
396
+ background: checked ? t.primary : t.border,
397
+ transition: "background 0.15s",
398
+ }}
399
+ >
400
+ <span
401
+ aria-hidden
402
+ style={{
403
+ position: "absolute",
404
+ top: 3,
405
+ left: checked ? 19 : 3,
406
+ width: 16,
407
+ height: 16,
408
+ borderRadius: "50%",
409
+ background: checked ? t.textPrimary : t.background,
410
+ transition: "left 0.15s",
411
+ }}
412
+ />
413
+ </button>
414
+ <span style={{ fontSize: 14, color: t.text }}>{label}</span>
415
+ {help}
416
+ </div>
417
+ );
418
+ }