@tribe-nest/forge 1.13.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 (255) hide show
  1. package/package.json +42 -0
  2. package/src/client/createForgeClient.ts +54 -0
  3. package/src/content/ForgeContentProvider.tsx +118 -0
  4. package/src/content/ForgeEditBridge.tsx +134 -0
  5. package/src/content/editFlag.ts +23 -0
  6. package/src/content/index.ts +28 -0
  7. package/src/content/resolve.ts +67 -0
  8. package/src/content/types.ts +97 -0
  9. package/src/content/useField.ts +64 -0
  10. package/src/contexts/AppAuthContext.tsx +185 -0
  11. package/src/contexts/AudioPlayerContext.tsx +389 -0
  12. package/src/contexts/CartContext.tsx +131 -0
  13. package/src/contexts/PublicAuthContext.tsx +333 -0
  14. package/src/contexts/useAppAdminGuard.ts +56 -0
  15. package/src/css.d.ts +5 -0
  16. package/src/data/collectionParams.ts +57 -0
  17. package/src/data/queries/useAccount.ts +71 -0
  18. package/src/data/queries/useAccountSettings.ts +82 -0
  19. package/src/data/queries/useAnalytics.ts +86 -0
  20. package/src/data/queries/useAuthActions.ts +28 -0
  21. package/src/data/queries/useBlog.ts +103 -0
  22. package/src/data/queries/useBlogComments.ts +96 -0
  23. package/src/data/queries/useBroadcasts.ts +31 -0
  24. package/src/data/queries/useCertificates.ts +58 -0
  25. package/src/data/queries/useChat.ts +267 -0
  26. package/src/data/queries/useCoachingAvailability.ts +69 -0
  27. package/src/data/queries/useCoachingProducts.ts +67 -0
  28. package/src/data/queries/useCohortPage.ts +17 -0
  29. package/src/data/queries/useCollections.ts +211 -0
  30. package/src/data/queries/useCommunity.ts +794 -0
  31. package/src/data/queries/useCoupons.ts +20 -0
  32. package/src/data/queries/useCourseAccess.ts +50 -0
  33. package/src/data/queries/useCourses.ts +91 -0
  34. package/src/data/queries/useCurrencies.ts +33 -0
  35. package/src/data/queries/useDocuments.ts +208 -0
  36. package/src/data/queries/useDonations.ts +29 -0
  37. package/src/data/queries/useEngagement.ts +115 -0
  38. package/src/data/queries/useEvents.ts +69 -0
  39. package/src/data/queries/useFinalize.ts +109 -0
  40. package/src/data/queries/useForms.ts +31 -0
  41. package/src/data/queries/useInvoice.ts +65 -0
  42. package/src/data/queries/useLeadMagnet.ts +96 -0
  43. package/src/data/queries/useMembership.ts +20 -0
  44. package/src/data/queries/useNotifications.ts +267 -0
  45. package/src/data/queries/useOffers.ts +56 -0
  46. package/src/data/queries/useOrders.ts +71 -0
  47. package/src/data/queries/usePWA.ts +101 -0
  48. package/src/data/queries/usePageActions.ts +36 -0
  49. package/src/data/queries/usePaymentFlow.ts +80 -0
  50. package/src/data/queries/usePaymentLink.ts +47 -0
  51. package/src/data/queries/usePodcast.ts +85 -0
  52. package/src/data/queries/usePostCollections.ts +48 -0
  53. package/src/data/queries/usePosts.ts +94 -0
  54. package/src/data/queries/useProducts.ts +108 -0
  55. package/src/data/queries/useReplay.ts +19 -0
  56. package/src/data/queries/useReviews.ts +122 -0
  57. package/src/data/queries/useShipping.ts +109 -0
  58. package/src/data/queries/useSubscriptions.ts +66 -0
  59. package/src/data/queries/useWebsite.ts +128 -0
  60. package/src/data/queries/useWebsiteAgent.ts +100 -0
  61. package/src/index.ts +98 -0
  62. package/src/provider/ForgeAppProvider.tsx +149 -0
  63. package/src/provider/ForgeProvider.tsx +91 -0
  64. package/src/provider/SiteConfigProvider.tsx +27 -0
  65. package/src/server/_tests/buildPwaHead.spec.ts +182 -0
  66. package/src/server/_tests/buildWebManifest.spec.ts +313 -0
  67. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
  68. package/src/server/index.ts +260 -0
  69. package/src/server/jobs.ts +104 -0
  70. package/src/server/pwa.ts +332 -0
  71. package/src/types/index.ts +74 -0
  72. package/src/types/models.ts +1313 -0
  73. package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
  74. package/src/ui/analytics/PageMetaPixel.tsx +92 -0
  75. package/src/ui/content/Editable.tsx +90 -0
  76. package/src/ui/format/PriceDisplay.tsx +142 -0
  77. package/src/ui/format/useFormatCurrency.ts +92 -0
  78. package/src/ui/headless/agent/useAiAgent.ts +106 -0
  79. package/src/ui/headless/auth/useLoginFlow.ts +72 -0
  80. package/src/ui/headless/auth/useSignupForm.ts +85 -0
  81. package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
  82. package/src/ui/headless/chat/attachmentKind.ts +27 -0
  83. package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
  84. package/src/ui/headless/chat/useChatRoom.ts +223 -0
  85. package/src/ui/headless/chat/useMessageThread.ts +60 -0
  86. package/src/ui/headless/checkout/useCheckout.ts +438 -0
  87. package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
  88. package/src/ui/headless/community/index.ts +20 -0
  89. package/src/ui/headless/community/lexical.ts +131 -0
  90. package/src/ui/headless/community/replyTree.ts +51 -0
  91. package/src/ui/headless/community/richLexical.ts +314 -0
  92. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
  93. package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
  94. package/src/ui/headless/community/useCommunityThread.ts +95 -0
  95. package/src/ui/headless/community/useMentionComposer.ts +105 -0
  96. package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
  97. package/src/ui/headless/consent/useCookieConsent.ts +81 -0
  98. package/src/ui/headless/course/useCourseCheckout.ts +99 -0
  99. package/src/ui/headless/dialog.tsx +14 -0
  100. package/src/ui/headless/document/index.ts +7 -0
  101. package/src/ui/headless/document/useContractSignature.ts +68 -0
  102. package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
  103. package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
  104. package/src/ui/headless/document/useSignaturePad.ts +180 -0
  105. package/src/ui/headless/donation/Donation.tsx +115 -0
  106. package/src/ui/headless/donation/DonationContext.tsx +223 -0
  107. package/src/ui/headless/donation/fees.ts +15 -0
  108. package/src/ui/headless/donation/index.ts +10 -0
  109. package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
  110. package/src/ui/headless/event/useEventCheckout.ts +141 -0
  111. package/src/ui/headless/forms/useContactForm.ts +58 -0
  112. package/src/ui/headless/forms/useEmailListForm.ts +75 -0
  113. package/src/ui/headless/forms/useSectionedForm.ts +256 -0
  114. package/src/ui/headless/index.ts +60 -0
  115. package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
  116. package/src/ui/headless/memberHome/index.ts +7 -0
  117. package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
  118. package/src/ui/headless/membership/MembershipGate.tsx +39 -0
  119. package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
  120. package/src/ui/headless/offer/Offer.tsx +88 -0
  121. package/src/ui/headless/offer/OfferContext.tsx +162 -0
  122. package/src/ui/headless/offer/index.ts +8 -0
  123. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
  124. package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
  125. package/src/ui/headless/posts/index.ts +13 -0
  126. package/src/ui/headless/posts/upsell.ts +41 -0
  127. package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
  128. package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
  129. package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
  130. package/src/ui/headless/posts/usePostItem.ts +81 -0
  131. package/src/ui/headless/reviews/index.ts +6 -0
  132. package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
  133. package/src/ui/headless/reviews/useReviewForm.ts +108 -0
  134. package/src/ui/headless/reviews/useReviewsList.ts +79 -0
  135. package/src/ui/headless/work/index.ts +25 -0
  136. package/src/ui/headless/work/useWorkPortal.ts +349 -0
  137. package/src/ui/index.ts +144 -0
  138. package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
  139. package/src/ui/payment/ForgeStripePayment.tsx +171 -0
  140. package/src/ui/shell/TribeNestApp.tsx +121 -0
  141. package/src/ui/shell/shellGating.spec.ts +28 -0
  142. package/src/ui/shell/shellGating.ts +11 -0
  143. package/src/ui/styled/AccountDashboard.tsx +575 -0
  144. package/src/ui/styled/AiAgentWidget.tsx +152 -0
  145. package/src/ui/styled/AudioPlayer.tsx +123 -0
  146. package/src/ui/styled/BlogCategory.tsx +79 -0
  147. package/src/ui/styled/BlogComments.tsx +236 -0
  148. package/src/ui/styled/BlogList.tsx +101 -0
  149. package/src/ui/styled/BlogPost.tsx +160 -0
  150. package/src/ui/styled/BlogPostFooter.tsx +33 -0
  151. package/src/ui/styled/BotProtection.tsx +101 -0
  152. package/src/ui/styled/Button.tsx +88 -0
  153. package/src/ui/styled/Cart.tsx +276 -0
  154. package/src/ui/styled/ChatRoom.tsx +252 -0
  155. package/src/ui/styled/Checkout.tsx +988 -0
  156. package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
  157. package/src/ui/styled/CoachingBooking.tsx +605 -0
  158. package/src/ui/styled/CoachingConfirmation.tsx +151 -0
  159. package/src/ui/styled/CoachingDetail.tsx +99 -0
  160. package/src/ui/styled/CohortPage.tsx +82 -0
  161. package/src/ui/styled/ConfirmSubscription.tsx +134 -0
  162. package/src/ui/styled/Confirmation.tsx +171 -0
  163. package/src/ui/styled/ContactForm.tsx +81 -0
  164. package/src/ui/styled/CookieConsent.tsx +239 -0
  165. package/src/ui/styled/CourseAccess.tsx +169 -0
  166. package/src/ui/styled/CourseCheckout.tsx +425 -0
  167. package/src/ui/styled/CourseConfirmation.tsx +191 -0
  168. package/src/ui/styled/CourseDetail.tsx +140 -0
  169. package/src/ui/styled/CoursesGrid.tsx +85 -0
  170. package/src/ui/styled/CurrencySwitcher.tsx +51 -0
  171. package/src/ui/styled/DonationButton.tsx +247 -0
  172. package/src/ui/styled/DonationPage.tsx +29 -0
  173. package/src/ui/styled/EmailListForm.tsx +88 -0
  174. package/src/ui/styled/EventConfirmation.tsx +228 -0
  175. package/src/ui/styled/EventCountdown.tsx +98 -0
  176. package/src/ui/styled/EventDetail.tsx +141 -0
  177. package/src/ui/styled/EventTickets.tsx +548 -0
  178. package/src/ui/styled/EventsList.tsx +83 -0
  179. package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
  180. package/src/ui/styled/FormRenderer.tsx +249 -0
  181. package/src/ui/styled/InstallBanner.tsx +114 -0
  182. package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
  183. package/src/ui/styled/InvoicePayment.tsx +141 -0
  184. package/src/ui/styled/LeadMagnet.tsx +93 -0
  185. package/src/ui/styled/Loading.tsx +56 -0
  186. package/src/ui/styled/LoginForm.tsx +134 -0
  187. package/src/ui/styled/Markdown.tsx +103 -0
  188. package/src/ui/styled/MembershipCheckout.tsx +197 -0
  189. package/src/ui/styled/MembershipTiers.tsx +115 -0
  190. package/src/ui/styled/OfferButton.tsx +181 -0
  191. package/src/ui/styled/PageActions.tsx +124 -0
  192. package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
  193. package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
  194. package/src/ui/styled/Paywall.tsx +54 -0
  195. package/src/ui/styled/PlayButton.tsx +48 -0
  196. package/src/ui/styled/PodcastEpisode.tsx +137 -0
  197. package/src/ui/styled/PodcastList.tsx +58 -0
  198. package/src/ui/styled/PodcastShow.tsx +164 -0
  199. package/src/ui/styled/PostsFeed.tsx +63 -0
  200. package/src/ui/styled/ProductDetail.tsx +616 -0
  201. package/src/ui/styled/ProductGrid.tsx +82 -0
  202. package/src/ui/styled/PushOptIn.tsx +65 -0
  203. package/src/ui/styled/PwaRegistration.tsx +58 -0
  204. package/src/ui/styled/ReactionBar.tsx +93 -0
  205. package/src/ui/styled/ReplayList.tsx +134 -0
  206. package/src/ui/styled/ResetPasswordForm.tsx +127 -0
  207. package/src/ui/styled/ReviewForm.tsx +292 -0
  208. package/src/ui/styled/ReviewsSection.tsx +260 -0
  209. package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
  210. package/src/ui/styled/SignupForm.tsx +109 -0
  211. package/src/ui/styled/UserMenu.tsx +166 -0
  212. package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
  213. package/src/ui/styled/chat/ChatComposer.tsx +153 -0
  214. package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
  215. package/src/ui/styled/chat/MessageBubble.tsx +66 -0
  216. package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
  217. package/src/ui/styled/community/CommunityComposer.tsx +358 -0
  218. package/src/ui/styled/community/CommunityFeed.tsx +483 -0
  219. package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
  220. package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
  221. package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
  222. package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
  223. package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
  224. package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
  225. package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
  226. package/src/ui/styled/community/CommunityRichText.tsx +157 -0
  227. package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
  228. package/src/ui/styled/community/MemberAvatar.tsx +74 -0
  229. package/src/ui/styled/community/index.ts +23 -0
  230. package/src/ui/styled/community/util.ts +15 -0
  231. package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
  232. package/src/ui/styled/members/MemberPostCard.tsx +392 -0
  233. package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
  234. package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
  235. package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
  236. package/src/ui/styled/members/index.ts +7 -0
  237. package/src/ui/styled/rich-text.css +150 -0
  238. package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
  239. package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
  240. package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
  241. package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
  242. package/src/ui/styled/work/WorkReportBody.tsx +327 -0
  243. package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
  244. package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
  245. package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
  246. package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
  247. package/src/ui/styled/work/index.ts +10 -0
  248. package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
  249. package/src/ui/theme/contrast.ts +22 -0
  250. package/src/utils/analyticsBus.ts +31 -0
  251. package/src/utils/attribution.ts +68 -0
  252. package/src/utils/cookieConsent.ts +83 -0
  253. package/src/utils/landing.ts +153 -0
  254. package/src/utils/metaPixel.ts +122 -0
  255. package/src/utils/structuredData.ts +126 -0
@@ -0,0 +1,483 @@
1
+ import { useEffect, useState, type CSSProperties } from "react";
2
+ import { MessageSquarePlus, Plus, Search, X } from "lucide-react";
3
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
4
+ import { useCommunitySpaceFeed } from "../../headless/community/useCommunitySpaceFeed";
5
+ import { useCommunitySearch, type CommunityPost, type CommunitySearchResults } from "../../../data/queries/useCommunity";
6
+ import { Loading } from "../Loading";
7
+ import { CommunityComposer } from "./CommunityComposer";
8
+ import { CommunityPostCard } from "./CommunityPostCard";
9
+
10
+ export interface CommunityFeedProps {
11
+ /** A space uuid OR its per-profile slug — resolved from the gated spaces list. */
12
+ spaceId: string;
13
+ onOpenPost: (post: CommunityPost) => void;
14
+ onOpenProfile?: (accountId: string) => void;
15
+ onBack?: () => void;
16
+ }
17
+
18
+ /**
19
+ * A space's post feed, live over the space's Socket.io room. Renders feed
20
+ * spaces as a card stream and forum spaces as compact title rows — same data,
21
+ * two shapes. The composer is hidden by default behind a "+" in the header (it
22
+ * cramps the top otherwise); a header search scopes FTS to THIS space and, while
23
+ * a query is active, replaces the feed with matching posts.
24
+ */
25
+ export function CommunityFeed({ spaceId: idOrSlug, onOpenPost, onOpenProfile, onBack }: CommunityFeedProps) {
26
+ const t = useThemeTokens();
27
+ const feed = useCommunitySpaceFeed(idOrSlug);
28
+ const border = `1px solid ${t.border}`;
29
+
30
+ // Composer is hidden by default — the "+" reveals it below the header.
31
+ const [composerOpen, setComposerOpen] = useState(false);
32
+ // Search expands from the header icon; a live query >=2 chars replaces the feed.
33
+ const [searchOpen, setSearchOpen] = useState(false);
34
+ const [query, setQuery] = useState("");
35
+ const trimmed = query.trim();
36
+ const searching = trimmed.length >= 2;
37
+
38
+ const [searchFocused, setSearchFocused] = useState(false);
39
+ // Debounce the query 500ms so we don't fire an FTS request per keystroke.
40
+ const [debouncedQuery, setDebouncedQuery] = useState("");
41
+ useEffect(() => {
42
+ const id = setTimeout(() => setDebouncedQuery(trimmed), 500);
43
+ return () => clearTimeout(id);
44
+ }, [trimmed]);
45
+
46
+ // Scope search to this space via the resolved space (id or slug both work).
47
+ const search = useCommunitySearch(debouncedQuery, feed.space?.id);
48
+
49
+ if (feed.spacesLoading || (feed.postsLoading && !feed.posts.length)) return <Loading />;
50
+
51
+ if (!feed.space) {
52
+ return (
53
+ <div style={{ border, borderRadius: t.cornerRadius, padding: 32, textAlign: "center", color: t.muted }}>
54
+ This space doesn't exist or your membership doesn't include it.
55
+ </div>
56
+ );
57
+ }
58
+
59
+ const isForum = feed.space.type === "forum";
60
+
61
+ const iconButton = (extra?: CSSProperties): CSSProperties => ({
62
+ flexShrink: 0,
63
+ display: "inline-flex",
64
+ alignItems: "center",
65
+ justifyContent: "center",
66
+ width: 32,
67
+ height: 32,
68
+ borderRadius: 999,
69
+ border,
70
+ background: "transparent",
71
+ color: t.text,
72
+ cursor: "pointer",
73
+ ...extra,
74
+ });
75
+
76
+ const closeSearch = () => {
77
+ setSearchOpen(false);
78
+ setQuery("");
79
+ };
80
+
81
+ return (
82
+ <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
83
+ <style>{"@keyframes forgeCommunitySearchIn{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}"}</style>
84
+ <header style={{ display: "flex", alignItems: "center", gap: 10 }}>
85
+ {onBack && (
86
+ <button
87
+ onClick={onBack}
88
+ aria-label="Back to spaces"
89
+ style={{ background: "none", border: "none", cursor: "pointer", color: t.text, fontSize: 18 }}
90
+ >
91
+
92
+ </button>
93
+ )}
94
+ <div style={{ minWidth: 0, flex: 1 }}>
95
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
96
+ <h2 style={{ margin: 0, fontSize: 18, fontFamily: t.headingFontFamily, color: t.text }}>
97
+ {feed.space.name}
98
+ </h2>
99
+ {/* Reveal/hide the composer (hidden by default). */}
100
+ <button
101
+ onClick={() => setComposerOpen((o) => !o)}
102
+ aria-label={composerOpen ? "Hide composer" : "New post"}
103
+ aria-expanded={composerOpen}
104
+ title={composerOpen ? "Hide composer" : isForum ? "Start a discussion" : "New post"}
105
+ style={iconButton({
106
+ width: 26,
107
+ height: 26,
108
+ background: composerOpen ? `${t.primary}1a` : "transparent",
109
+ color: composerOpen ? t.primary : t.text,
110
+ })}
111
+ >
112
+ <Plus size={15} style={{ transform: composerOpen ? "rotate(45deg)" : "none", transition: "transform .15s" }} />
113
+ </button>
114
+ </div>
115
+ {feed.space.description && (
116
+ <p style={{ margin: "2px 0 0", fontSize: 13, color: t.muted }}>{feed.space.description}</p>
117
+ )}
118
+ {feed.memberCount != null && (
119
+ <p style={{ margin: "2px 0 0", fontSize: 12, color: t.muted }}>
120
+ {Number(feed.memberCount)} {Number(feed.memberCount) === 1 ? "member" : "members"}
121
+ </p>
122
+ )}
123
+ </div>
124
+
125
+ {/* Search toggle only — the input itself is a full-width bar below the
126
+ header (see below) so it never squeezes the title on a narrow column. */}
127
+ <button
128
+ onClick={() => (searchOpen ? closeSearch() : setSearchOpen(true))}
129
+ aria-label={searchOpen ? "Close search" : "Search this space"}
130
+ aria-expanded={searchOpen}
131
+ title="Search this space"
132
+ style={iconButton({
133
+ background: searchOpen ? `${t.primary}1a` : "transparent",
134
+ color: searchOpen ? t.primary : t.text,
135
+ })}
136
+ >
137
+ {searchOpen ? <X size={15} /> : <Search size={15} />}
138
+ </button>
139
+
140
+ {feed.isJoined && (
141
+ <button
142
+ onClick={() => !feed.joinPending && feed.leave()}
143
+ title="Leave this space (stops new-post notifications)"
144
+ style={{
145
+ flexShrink: 0,
146
+ border: `1px solid ${t.border}`,
147
+ borderRadius: 999,
148
+ background: "transparent",
149
+ color: t.text,
150
+ fontSize: 12,
151
+ fontWeight: 600,
152
+ padding: "5px 14px",
153
+ cursor: feed.joinPending ? "default" : "pointer",
154
+ opacity: feed.joinPending ? 0.6 : 1,
155
+ }}
156
+ >
157
+ Joined
158
+ </button>
159
+ )}
160
+ </header>
161
+
162
+ {/* Full-width search bar — lives below the header so it never distorts
163
+ the title row; focus lifts a soft primary ring. */}
164
+ {searchOpen && (
165
+ <div
166
+ style={{
167
+ display: "flex",
168
+ alignItems: "center",
169
+ gap: 10,
170
+ width: "100%",
171
+ padding: "10px 14px",
172
+ borderRadius: 999,
173
+ background: t.surface,
174
+ border: `1px solid ${searchFocused ? t.primary : t.border}`,
175
+ boxShadow: searchFocused ? `0 0 0 3px ${t.primary}22` : "none",
176
+ transition: "border-color .15s, box-shadow .15s",
177
+ animation: "forgeCommunitySearchIn .18s ease",
178
+ }}
179
+ >
180
+ <Search size={16} style={{ color: searchFocused ? t.primary : t.muted, flexShrink: 0 }} />
181
+ <input
182
+ autoFocus
183
+ value={query}
184
+ onChange={(e) => setQuery(e.target.value)}
185
+ onFocus={() => setSearchFocused(true)}
186
+ onBlur={() => setSearchFocused(false)}
187
+ onKeyDown={(e) => {
188
+ if (e.key === "Escape") closeSearch();
189
+ }}
190
+ placeholder="Search this space…"
191
+ style={{
192
+ flex: 1,
193
+ minWidth: 0,
194
+ border: "none",
195
+ outline: "none",
196
+ background: "transparent",
197
+ color: t.text,
198
+ fontSize: 14,
199
+ }}
200
+ />
201
+ {query && (
202
+ <button
203
+ onClick={() => setQuery("")}
204
+ aria-label="Clear search"
205
+ style={iconButton({ width: 22, height: 22, border: "none", color: t.muted })}
206
+ >
207
+ <X size={14} />
208
+ </button>
209
+ )}
210
+ </div>
211
+ )}
212
+
213
+ {/* Un-joined viewers can still read + post (tier access governs that) —
214
+ the banner nudges them to opt into new-post notifications. */}
215
+ {!feed.isJoined && (
216
+ <div
217
+ style={{
218
+ display: "flex",
219
+ alignItems: "center",
220
+ justifyContent: "space-between",
221
+ gap: 10,
222
+ border: `1px solid ${t.primary}33`,
223
+ background: `${t.primary}0d`,
224
+ borderRadius: t.cornerRadius,
225
+ padding: "8px 12px",
226
+ }}
227
+ >
228
+ <span style={{ fontSize: 13, color: t.text }}>Join this space to get notified about new posts.</span>
229
+ <button
230
+ onClick={() => !feed.joinPending && feed.join()}
231
+ style={{
232
+ flexShrink: 0,
233
+ border: "none",
234
+ borderRadius: 999,
235
+ background: t.primary,
236
+ color: t.textPrimary,
237
+ fontSize: 12,
238
+ fontWeight: 600,
239
+ padding: "5px 14px",
240
+ cursor: feed.joinPending ? "default" : "pointer",
241
+ opacity: feed.joinPending ? 0.6 : 1,
242
+ }}
243
+ >
244
+ Join
245
+ </button>
246
+ </div>
247
+ )}
248
+
249
+ {/* Composer — hidden until toggled; behavior/props identical to before. */}
250
+ {composerOpen && (
251
+ <div style={{ position: "relative" }}>
252
+ <CommunityComposer
253
+ withTitle={isForum}
254
+ placeholder={isForum ? "Start a discussion…" : "Share something with the community…"}
255
+ submitLabel={isForum ? "Start discussion" : "Post"}
256
+ compact={!isForum}
257
+ onSubmit={async (input) => {
258
+ await feed.createPost.mutateAsync(input);
259
+ setComposerOpen(false);
260
+ }}
261
+ />
262
+ <button
263
+ onClick={() => setComposerOpen(false)}
264
+ aria-label="Close composer"
265
+ title="Close composer"
266
+ style={{
267
+ position: "absolute",
268
+ top: 8,
269
+ right: 8,
270
+ display: "inline-flex",
271
+ alignItems: "center",
272
+ justifyContent: "center",
273
+ width: 26,
274
+ height: 26,
275
+ borderRadius: 999,
276
+ border: "none",
277
+ background: "transparent",
278
+ color: t.muted,
279
+ cursor: "pointer",
280
+ }}
281
+ >
282
+ <X size={15} />
283
+ </button>
284
+ </div>
285
+ )}
286
+
287
+ {/* While searching, results replace the feed; clearing returns to it. */}
288
+ {searching ? (
289
+ <SearchResults
290
+ results={search.data}
291
+ // Treat the debounce window (query typed but not yet requested) as
292
+ // loading so we never flash "no matches" before the request fires.
293
+ loading={search.isLoading || search.isFetching || debouncedQuery !== trimmed}
294
+ query={debouncedQuery || trimmed}
295
+ isForum={isForum}
296
+ onOpenPost={onOpenPost}
297
+ spaceId={feed.space.id}
298
+ />
299
+ ) : feed.posts.length === 0 ? (
300
+ <div
301
+ style={{
302
+ border,
303
+ borderRadius: t.cornerRadius,
304
+ padding: "44px 24px",
305
+ textAlign: "center",
306
+ display: "flex",
307
+ flexDirection: "column",
308
+ alignItems: "center",
309
+ gap: 6,
310
+ }}
311
+ >
312
+ <div
313
+ style={{
314
+ display: "inline-flex",
315
+ alignItems: "center",
316
+ justifyContent: "center",
317
+ width: 48,
318
+ height: 48,
319
+ borderRadius: 999,
320
+ background: `${t.primary}14`,
321
+ color: t.primary,
322
+ marginBottom: 6,
323
+ }}
324
+ >
325
+ <MessageSquarePlus size={22} />
326
+ </div>
327
+ <div style={{ fontSize: 16, fontWeight: 600, color: t.text }}>
328
+ {isForum ? "No discussions yet" : "No posts yet"}
329
+ </div>
330
+ <div style={{ fontSize: 13, color: t.muted, maxWidth: 300 }}>
331
+ {isForum
332
+ ? "Kick things off — start the first discussion in this space."
333
+ : "Be the first to share something with the community."}
334
+ </div>
335
+ <button
336
+ onClick={() => setComposerOpen(true)}
337
+ style={{
338
+ marginTop: 10,
339
+ border: "none",
340
+ borderRadius: 999,
341
+ background: t.primary,
342
+ color: t.textPrimary,
343
+ fontSize: 13,
344
+ fontWeight: 600,
345
+ padding: "8px 18px",
346
+ cursor: "pointer",
347
+ }}
348
+ >
349
+ {isForum ? "Start a discussion" : "Write the first post"}
350
+ </button>
351
+ </div>
352
+ ) : (
353
+ <div style={{ display: "flex", flexDirection: "column", gap: isForum ? 8 : 14 }}>
354
+ {feed.posts.map((post) => (
355
+ <CommunityPostCard
356
+ key={post.id}
357
+ post={post}
358
+ variant={isForum ? "forum" : "feed"}
359
+ onOpen={onOpenPost}
360
+ onOpenProfile={onOpenProfile}
361
+ onReact={(emoji) => feed.react(post.id, emoji)}
362
+ />
363
+ ))}
364
+ </div>
365
+ )}
366
+
367
+ {!searching && feed.hasMore && (
368
+ <button
369
+ onClick={() => feed.fetchNextPage()}
370
+ style={{
371
+ alignSelf: "center",
372
+ background: "none",
373
+ border,
374
+ borderRadius: t.cornerRadius,
375
+ padding: "6px 14px",
376
+ cursor: "pointer",
377
+ color: t.text,
378
+ fontSize: 13,
379
+ }}
380
+ >
381
+ Load more
382
+ </button>
383
+ )}
384
+ </div>
385
+ );
386
+ }
387
+
388
+ /**
389
+ * In-space search results — post matches (title + snippet) that navigate to the
390
+ * thread via onOpenPost, and reply matches surfaced by their parent post. Search
391
+ * rows are lean FTS projections, so we hand onOpenPost a minimal post shape
392
+ * (callers route by id).
393
+ */
394
+ function SearchResults({
395
+ results,
396
+ loading,
397
+ query,
398
+ isForum,
399
+ onOpenPost,
400
+ spaceId,
401
+ }: {
402
+ results: CommunitySearchResults | undefined;
403
+ loading: boolean;
404
+ query: string;
405
+ isForum: boolean;
406
+ onOpenPost: (post: CommunityPost) => void;
407
+ spaceId: string;
408
+ }) {
409
+ const t = useThemeTokens();
410
+ const border = `1px solid ${t.border}`;
411
+
412
+ if (loading) return <Loading />;
413
+
414
+ const posts = results?.posts ?? [];
415
+ const replies = results?.replies ?? [];
416
+
417
+ if (posts.length === 0 && replies.length === 0) {
418
+ return (
419
+ <div style={{ border, borderRadius: t.cornerRadius, padding: 28, textAlign: "center", color: t.muted }}>
420
+ No matches for “{query}”.
421
+ </div>
422
+ );
423
+ }
424
+
425
+ const snippet = (text: string | null, len = 140) => {
426
+ const clean = (text ?? "").replace(/\s+/g, " ").trim();
427
+ return clean.length > len ? clean.slice(0, len) + "…" : clean;
428
+ };
429
+
430
+ const openById = (id: string, title: string | null, bodyText: string | null) =>
431
+ // Minimal post shape — the FTS row carries no engagement/media; callers only
432
+ // read the id to route, and the thread view refetches the full post.
433
+ onOpenPost({
434
+ id,
435
+ spaceId,
436
+ title,
437
+ bodyText,
438
+ profileId: "",
439
+ authorAccountId: "",
440
+ body: null,
441
+ isPinned: false,
442
+ isLocked: false,
443
+ replyCount: 0,
444
+ reactionCount: 0,
445
+ lastActivityAt: null,
446
+ createdAt: new Date().toISOString(),
447
+ author: null,
448
+ media: [],
449
+ });
450
+
451
+ const rowStyle: CSSProperties = {
452
+ textAlign: "left",
453
+ width: "100%",
454
+ border,
455
+ borderRadius: t.cornerRadius,
456
+ background: t.surface,
457
+ padding: "12px 14px",
458
+ cursor: "pointer",
459
+ color: t.text,
460
+ display: "flex",
461
+ flexDirection: "column",
462
+ gap: 4,
463
+ };
464
+
465
+ return (
466
+ <div style={{ display: "flex", flexDirection: "column", gap: isForum ? 8 : 10 }}>
467
+ {posts.map((p) => (
468
+ <button key={`post-${p.id}`} onClick={() => openById(p.id, p.title, p.bodyText)} style={rowStyle}>
469
+ <span style={{ fontWeight: 700, fontSize: 15 }}>{p.title || snippet(p.bodyText, 60) || "Untitled"}</span>
470
+ {p.bodyText && (
471
+ <span style={{ fontSize: 13, color: t.muted }}>{snippet(p.bodyText)}</span>
472
+ )}
473
+ </button>
474
+ ))}
475
+ {replies.map((r) => (
476
+ <button key={`reply-${r.id}`} onClick={() => openById(r.postId, r.postTitle, null)} style={rowStyle}>
477
+ <span style={{ fontSize: 12, color: t.muted }}>Reply in “{r.postTitle || "a discussion"}”</span>
478
+ {r.bodyText && <span style={{ fontSize: 13, color: t.text }}>{snippet(r.bodyText)}</span>}
479
+ </button>
480
+ ))}
481
+ </div>
482
+ );
483
+ }
@@ -0,0 +1,109 @@
1
+ import { useState } from "react";
2
+ import { Trophy } from "lucide-react";
3
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
4
+ import { usePublicAuth } from "../../../contexts/PublicAuthContext";
5
+ import {
6
+ communityMemberName,
7
+ useCommunityLeaderboard,
8
+ type CommunityLeaderboardPeriod,
9
+ } from "../../../data/queries/useCommunity";
10
+ import { Loading } from "../Loading";
11
+ import { LevelBadge, MemberAvatar } from "./MemberAvatar";
12
+
13
+ const PERIODS: { value: CommunityLeaderboardPeriod; label: string }[] = [
14
+ { value: "7", label: "This week" },
15
+ { value: "30", label: "This month" },
16
+ { value: "all", label: "All time" },
17
+ ];
18
+
19
+ export interface CommunityLeaderboardProps {
20
+ onOpenProfile?: (accountId: string) => void;
21
+ }
22
+
23
+ /** The points leaderboard with 7-day / 30-day / all-time tabs. */
24
+ export function CommunityLeaderboard({ onOpenProfile }: CommunityLeaderboardProps) {
25
+ const t = useThemeTokens();
26
+ const { user } = usePublicAuth();
27
+ const [period, setPeriod] = useState<CommunityLeaderboardPeriod>("7");
28
+ const { data: entries = [], isLoading } = useCommunityLeaderboard(period);
29
+ const border = `1px solid ${t.border}`;
30
+
31
+ return (
32
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
33
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, flexWrap: "wrap" }}>
34
+ <h2 style={{ margin: 0, fontSize: 18, fontFamily: t.headingFontFamily, color: t.text, display: "flex", alignItems: "center", gap: 8 }}>
35
+ <Trophy size={18} style={{ color: t.primary }} /> Leaderboard
36
+ </h2>
37
+ <div style={{ display: "flex", gap: 6 }}>
38
+ {PERIODS.map((p) => {
39
+ const active = p.value === period;
40
+ return (
41
+ <button
42
+ key={p.value}
43
+ onClick={() => setPeriod(p.value)}
44
+ style={{
45
+ background: active ? t.primary : "transparent",
46
+ color: active ? t.textPrimary : t.text,
47
+ border: active ? "none" : border,
48
+ borderRadius: 999,
49
+ padding: "4px 12px",
50
+ fontSize: 13,
51
+ fontWeight: 600,
52
+ cursor: "pointer",
53
+ }}
54
+ >
55
+ {p.label}
56
+ </button>
57
+ );
58
+ })}
59
+ </div>
60
+ </div>
61
+
62
+ {isLoading ? (
63
+ <Loading />
64
+ ) : entries.length === 0 ? (
65
+ <div style={{ border, borderRadius: t.cornerRadius, padding: 28, textAlign: "center", color: t.muted }}>
66
+ No activity in this period yet.
67
+ </div>
68
+ ) : (
69
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
70
+ {entries.map((entry, i) => {
71
+ const isMe = entry.accountId === user?.id;
72
+ const medal = i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : null;
73
+ return (
74
+ <div
75
+ key={entry.accountId}
76
+ onClick={() => onOpenProfile?.(entry.accountId)}
77
+ role={onOpenProfile ? "button" : undefined}
78
+ style={{
79
+ display: "flex",
80
+ alignItems: "center",
81
+ gap: 12,
82
+ border: `1px solid ${isMe ? `${t.primary}66` : t.border}`,
83
+ background: isMe ? `${t.primary}0d` : t.surface,
84
+ borderRadius: t.cornerRadius,
85
+ padding: "10px 14px",
86
+ cursor: onOpenProfile ? "pointer" : "default",
87
+ }}
88
+ >
89
+ <span style={{ width: 28, textAlign: "center", fontWeight: 700, color: t.muted, fontSize: medal ? 18 : 14 }}>
90
+ {medal ?? i + 1}
91
+ </span>
92
+ <MemberAvatar member={entry} size={34} />
93
+ <span style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 6 }}>
94
+ <span style={{ fontWeight: 600, color: t.text, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
95
+ {communityMemberName(entry)}
96
+ {isMe ? " (you)" : ""}
97
+ </span>
98
+ <LevelBadge level={entry.level} />
99
+ </span>
100
+ <span style={{ fontWeight: 800, color: t.primary }}>{entry.points}</span>
101
+ <span style={{ fontSize: 11, color: t.muted }}>pts</span>
102
+ </div>
103
+ );
104
+ })}
105
+ </div>
106
+ )}
107
+ </div>
108
+ );
109
+ }