@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,122 @@
1
+ import { useEffect, useMemo } from "react";
2
+ import { useQueryClient } from "@tanstack/react-query";
3
+ import { usePublicAuth } from "../../../contexts/PublicAuthContext";
4
+ import {
5
+ applyReactionDelta,
6
+ bumpFeedReplyCount,
7
+ prependPostToFeed,
8
+ useCommunitySocket,
9
+ useCommunitySpacePosts,
10
+ useCommunitySpaces,
11
+ useCreateCommunityPost,
12
+ useJoinCommunitySpace,
13
+ useLeaveCommunitySpace,
14
+ useToggleCommunityReaction,
15
+ type CommunityPost,
16
+ type CommunityReactionEvent,
17
+ type CommunityReply,
18
+ } from "../../../data/queries/useCommunity";
19
+
20
+ /**
21
+ * Headless space feed: the space (from the gated spaces list), its paginated
22
+ * posts, realtime new-post/reaction updates over the shared Socket.io gateway
23
+ * (room `community:<spaceId>`, joined via `communityJoinSpace` exactly like
24
+ * chat joins channels), plus create-post and toggle-reaction actions.
25
+ *
26
+ * The single primitive a creator needs to rebuild a space feed page.
27
+ *
28
+ * `idOrSlug` accepts either a space uuid or its per-profile slug — the space is
29
+ * resolved from the gated spaces list by id OR slug, so slug-addressed deep
30
+ * links load the right space without a separate lookup.
31
+ */
32
+ export function useCommunitySpaceFeed(idOrSlug?: string) {
33
+ const { user } = usePublicAuth();
34
+ const queryClient = useQueryClient();
35
+
36
+ const { data: spaces = [], isLoading: spacesLoading } = useCommunitySpaces();
37
+ const space = useMemo(
38
+ () => spaces.find((s) => s.id === idOrSlug || s.slug === idOrSlug) ?? null,
39
+ [spaces, idOrSlug],
40
+ );
41
+ // Downstream hooks (posts, realtime room, create) are keyed by the canonical
42
+ // space uuid — only wire them up once the space resolves from the list, so a
43
+ // slug-addressed link never leaks a slug into a uuid-keyed request/room.
44
+ const spaceId = space?.id;
45
+
46
+ const {
47
+ data: postPages,
48
+ isLoading: postsLoading,
49
+ fetchNextPage,
50
+ hasNextPage,
51
+ isFetchingNextPage,
52
+ } = useCommunitySpacePosts(spaceId);
53
+ const posts = useMemo(() => postPages?.pages.flatMap((p) => p.data) ?? [], [postPages]);
54
+
55
+ const createPost = useCreateCommunityPost(spaceId);
56
+ const toggleReaction = useToggleCommunityReaction();
57
+ const joinSpace = useJoinCommunitySpace();
58
+ const leaveSpace = useLeaveCommunitySpace();
59
+
60
+ // ── Realtime ───────────────────────────────────────────────────────────────
61
+ const socket = useCommunitySocket(!!user);
62
+
63
+ useEffect(() => {
64
+ if (!socket || !spaceId) return;
65
+ const onMessage = (data: {
66
+ event?: string;
67
+ data?: {
68
+ spaceId?: string;
69
+ postId?: string;
70
+ post?: CommunityPost;
71
+ reply?: CommunityReply;
72
+ } & Partial<CommunityReactionEvent>;
73
+ }) => {
74
+ if (data.event === "communityNewPost" && data.data?.spaceId === spaceId && data.data?.post) {
75
+ prependPostToFeed(queryClient, spaceId, data.data.post);
76
+ }
77
+ if (data.event === "communityNewReply" && data.data?.spaceId === spaceId && data.data?.postId) {
78
+ bumpFeedReplyCount(queryClient, spaceId, data.data.postId);
79
+ }
80
+ if (data.event === "communityReaction" && data.data?.entityId && data.data?.accountId !== user?.id) {
81
+ applyReactionDelta(queryClient, data.data as CommunityReactionEvent);
82
+ }
83
+ };
84
+ socket.on("message", onMessage);
85
+ return () => {
86
+ socket.off("message", onMessage);
87
+ };
88
+ }, [socket, spaceId, queryClient, user?.id]);
89
+
90
+ // Join/leave the space room (mirrors chatJoinChannel).
91
+ useEffect(() => {
92
+ if (!spaceId || !socket) return;
93
+ const join = () => socket.emit("communityJoinSpace", { spaceId });
94
+ if (socket.connected) join();
95
+ socket.on("connect", join);
96
+ return () => {
97
+ socket.off("connect", join);
98
+ socket.emit("communityLeaveSpace", { spaceId });
99
+ };
100
+ }, [spaceId, socket]);
101
+
102
+ return {
103
+ space,
104
+ spaces,
105
+ spacesLoading,
106
+ posts,
107
+ postsLoading,
108
+ fetchNextPage,
109
+ hasMore: !!hasNextPage && !isFetchingNextPage,
110
+ currentAccountId: user?.id,
111
+ // Belonging — tiers govern access, join governs new-post notifications.
112
+ isJoined: space?.isJoined ?? false,
113
+ memberCount: space?.memberCount ?? null,
114
+ joinPending: joinSpace.isPending || leaveSpace.isPending,
115
+ // Actions
116
+ createPost,
117
+ join: () => spaceId && joinSpace.mutate(spaceId),
118
+ leave: () => spaceId && leaveSpace.mutate(spaceId),
119
+ react: (entityId: string, emoji: string) =>
120
+ toggleReaction.mutate({ entityType: "community_post", entityId, emoji, spaceId }),
121
+ };
122
+ }
@@ -0,0 +1,95 @@
1
+ import { useEffect, useMemo } from "react";
2
+ import { useQueryClient } from "@tanstack/react-query";
3
+ import { usePublicAuth } from "../../../contexts/PublicAuthContext";
4
+ import {
5
+ appendReplyToThread,
6
+ applyReactionDelta,
7
+ useCommunityPost,
8
+ useCommunityReplies,
9
+ useCommunitySocket,
10
+ useCreateCommunityReply,
11
+ useToggleCommunityReaction,
12
+ type CommunityReactionEvent,
13
+ type CommunityReply,
14
+ type ReactableEntityType,
15
+ } from "../../../data/queries/useCommunity";
16
+ import { buildReplyTree } from "./replyTree";
17
+
18
+ /**
19
+ * Headless post thread: the post, its full reply tree (assembled from the flat
20
+ * pages — data nesting is unlimited, display capping is the renderer's job),
21
+ * realtime reply/reaction updates via the post's space room, plus reply and
22
+ * toggle-reaction actions.
23
+ */
24
+ export function useCommunityThread(postId?: string) {
25
+ const { user } = usePublicAuth();
26
+ const queryClient = useQueryClient();
27
+
28
+ const { data: post, isLoading: postLoading, error: postError } = useCommunityPost(postId);
29
+ const {
30
+ data: replyPages,
31
+ isLoading: repliesLoading,
32
+ fetchNextPage,
33
+ hasNextPage,
34
+ isFetchingNextPage,
35
+ } = useCommunityReplies(postId);
36
+
37
+ const replies = useMemo(() => replyPages?.pages.flatMap((p) => p.data) ?? [], [replyPages]);
38
+ const replyTree = useMemo(() => buildReplyTree(replies), [replies]);
39
+
40
+ const createReply = useCreateCommunityReply(postId);
41
+ const toggleReaction = useToggleCommunityReaction();
42
+
43
+ const spaceId = post?.spaceId;
44
+
45
+ // ── Realtime (same space room the feed uses) ──────────────────────────────
46
+ const socket = useCommunitySocket(!!user);
47
+
48
+ useEffect(() => {
49
+ if (!socket || !postId) return;
50
+ const onMessage = (data: {
51
+ event?: string;
52
+ data?: { spaceId?: string; postId?: string; reply?: CommunityReply } & Partial<CommunityReactionEvent>;
53
+ }) => {
54
+ if (data.event === "communityNewReply" && data.data?.postId === postId && data.data?.reply) {
55
+ // Skip own replies — the mutation already appended them.
56
+ if (data.data.reply.authorAccountId === user?.id) return;
57
+ appendReplyToThread(queryClient, postId, data.data.reply);
58
+ }
59
+ if (data.event === "communityReaction" && data.data?.entityId && data.data?.accountId !== user?.id) {
60
+ applyReactionDelta(queryClient, data.data as CommunityReactionEvent, { postId });
61
+ }
62
+ };
63
+ socket.on("message", onMessage);
64
+ return () => {
65
+ socket.off("message", onMessage);
66
+ };
67
+ }, [socket, postId, queryClient, user?.id]);
68
+
69
+ useEffect(() => {
70
+ if (!spaceId || !socket) return;
71
+ const join = () => socket.emit("communityJoinSpace", { spaceId });
72
+ if (socket.connected) join();
73
+ socket.on("connect", join);
74
+ return () => {
75
+ socket.off("connect", join);
76
+ socket.emit("communityLeaveSpace", { spaceId });
77
+ };
78
+ }, [spaceId, socket]);
79
+
80
+ return {
81
+ post,
82
+ postLoading,
83
+ postError,
84
+ replies,
85
+ replyTree,
86
+ repliesLoading,
87
+ fetchNextPage,
88
+ hasMore: !!hasNextPage && !isFetchingNextPage,
89
+ currentAccountId: user?.id,
90
+ // Actions
91
+ createReply,
92
+ react: (entityType: Extract<ReactableEntityType, "community_post" | "community_reply">, entityId: string, emoji: string) =>
93
+ toggleReaction.mutate({ entityType, entityId, emoji, spaceId, postId }),
94
+ };
95
+ }
@@ -0,0 +1,105 @@
1
+ import { useCallback, useMemo, useState } from "react";
2
+ import {
3
+ communityMemberName,
4
+ useCommunityMentionDirectory,
5
+ type CommunityLeaderboardEntry,
6
+ } from "../../../data/queries/useCommunity";
7
+ import { buildLexicalState, mentionsInText, type MentionRef } from "./lexical";
8
+
9
+ export interface MentionSuggestion {
10
+ accountId: string;
11
+ name: string;
12
+ avatarUrl: string | null;
13
+ level: number;
14
+ }
15
+
16
+ /**
17
+ * Headless @mention textarea state machine. Track the draft + caret; when the
18
+ * text before the caret ends in an `@query` token, `suggestions` lights up with
19
+ * matching members (from the community member directory). `insertMention`
20
+ * replaces the token and records the ref so `getBody()` can emit Lexical
21
+ * mention nodes + explicit mentionedAccountIds.
22
+ */
23
+ export function useMentionComposer(initialText = "") {
24
+ const [text, setText] = useState(initialText);
25
+ const [caret, setCaret] = useState(initialText.length);
26
+ const [trackedMentions, setTrackedMentions] = useState<MentionRef[]>([]);
27
+ const { data: directory = [] } = useCommunityMentionDirectory();
28
+
29
+ const members: MentionSuggestion[] = useMemo(
30
+ () =>
31
+ directory.map((m: CommunityLeaderboardEntry) => ({
32
+ accountId: m.accountId,
33
+ name: communityMemberName(m),
34
+ avatarUrl: m.avatarUrl,
35
+ level: m.level,
36
+ })),
37
+ [directory],
38
+ );
39
+
40
+ /** The active "@query" token immediately before the caret, if any. */
41
+ const activeToken = useMemo(() => {
42
+ const upToCaret = text.slice(0, caret);
43
+ const match = /(^|\s)@([^\s@]{0,40})$/.exec(upToCaret);
44
+ if (!match) return null;
45
+ return { query: match[2], start: caret - match[2].length - 1 };
46
+ }, [text, caret]);
47
+
48
+ const suggestions: MentionSuggestion[] = useMemo(() => {
49
+ if (!activeToken) return [];
50
+ const q = activeToken.query.toLowerCase();
51
+ return members
52
+ .filter((m) => m.name.toLowerCase().includes(q))
53
+ .slice(0, 6);
54
+ }, [activeToken, members]);
55
+
56
+ const onTextChange = useCallback((nextText: string, nextCaret: number) => {
57
+ setText(nextText);
58
+ setCaret(nextCaret);
59
+ }, []);
60
+
61
+ const insertMention = useCallback(
62
+ (member: MentionSuggestion) => {
63
+ if (!activeToken) return;
64
+ const before = text.slice(0, activeToken.start);
65
+ const after = text.slice(caret);
66
+ const inserted = `@${member.name} `;
67
+ const nextText = before + inserted + after;
68
+ setText(nextText);
69
+ setCaret(before.length + inserted.length);
70
+ setTrackedMentions((prev) =>
71
+ prev.some((m) => m.accountId === member.accountId)
72
+ ? prev
73
+ : [...prev, { accountId: member.accountId, name: member.name }],
74
+ );
75
+ },
76
+ [activeToken, text, caret],
77
+ );
78
+
79
+ /** Mentions that still literally appear in the draft (survive edits/deletes). */
80
+ const liveMentions = useMemo(() => mentionsInText(text, trackedMentions), [text, trackedMentions]);
81
+
82
+ /** Lexical editor state for the current draft, mention nodes included. */
83
+ const getBody = useCallback(() => buildLexicalState(text, liveMentions), [text, liveMentions]);
84
+
85
+ const reset = useCallback(() => {
86
+ setText("");
87
+ setCaret(0);
88
+ setTrackedMentions([]);
89
+ }, []);
90
+
91
+ return {
92
+ text,
93
+ onTextChange,
94
+ setCaret,
95
+ isEmpty: !text.trim(),
96
+ // Autocomplete
97
+ suggestions,
98
+ isSuggesting: !!activeToken && suggestions.length > 0,
99
+ insertMention,
100
+ // Submission
101
+ getBody,
102
+ mentionedAccountIds: liveMentions.map((m) => m.accountId),
103
+ reset,
104
+ };
105
+ }
@@ -0,0 +1,193 @@
1
+ import { useCallback, useMemo, useRef, useState } from "react";
2
+ import {
3
+ communityMemberName,
4
+ useCommunityMentionDirectory,
5
+ type CommunityLeaderboardEntry,
6
+ } from "../../../data/queries/useCommunity";
7
+ import type { MentionSuggestion } from "./useMentionComposer";
8
+ import { collectMentions, isRichEmpty, serializeRichToLexical } from "./richLexical";
9
+
10
+ export type { MentionSuggestion } from "./useMentionComposer";
11
+
12
+ export type RichCommand = "bold" | "italic" | "underline" | "insertUnorderedList" | "insertOrderedList";
13
+
14
+ /** The active "@query" token immediately before the caret, if any. */
15
+ interface ActiveToken {
16
+ query: string;
17
+ /** The text node holding the token, and the char offset where `@` starts. */
18
+ textNode: Text;
19
+ atOffset: number;
20
+ caretOffset: number;
21
+ }
22
+
23
+ /**
24
+ * Headless rich-text + @mention composer over a contentEditable div. The DOM is
25
+ * the source of truth: formatting goes through `document.execCommand`, mentions
26
+ * are inserted as non-editable chip spans, and `getBody()` serializes the live
27
+ * DOM to the Lexical JSON CommunityRichText renders. Replaces the plain-text
28
+ * `useMentionComposer` for the community composer while keeping its onSubmit
29
+ * contract (body / mentionedAccountIds).
30
+ */
31
+ export function useRichMentionComposer() {
32
+ const editorRef = useRef<HTMLDivElement | null>(null);
33
+ const [activeToken, setActiveToken] = useState<ActiveToken | null>(null);
34
+ // Bumps whenever DOM content changes so `isEmpty` recomputes.
35
+ const [tick, setTick] = useState(0);
36
+ const bump = useCallback(() => setTick((n) => n + 1), []);
37
+
38
+ const { data: directory = [] } = useCommunityMentionDirectory();
39
+
40
+ const members: MentionSuggestion[] = useMemo(
41
+ () =>
42
+ directory.map((m: CommunityLeaderboardEntry) => ({
43
+ accountId: m.accountId,
44
+ name: communityMemberName(m),
45
+ avatarUrl: m.avatarUrl,
46
+ level: m.level,
47
+ })),
48
+ [directory],
49
+ );
50
+
51
+ const suggestions: MentionSuggestion[] = useMemo(() => {
52
+ if (!activeToken) return [];
53
+ const q = activeToken.query.toLowerCase();
54
+ return members.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 6);
55
+ }, [activeToken, members]);
56
+
57
+ /** Recompute the active @query token from the current selection. */
58
+ const detectToken = useCallback(() => {
59
+ const editor = editorRef.current;
60
+ if (!editor) return setActiveToken(null);
61
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
62
+ if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return setActiveToken(null);
63
+
64
+ const range = sel.getRangeAt(0);
65
+ const node = range.startContainer;
66
+ // Only text nodes inside the editor carry a typeable @query.
67
+ if (node.nodeType !== 3 || !editor.contains(node)) return setActiveToken(null);
68
+ const textNode = node as Text;
69
+ const caretOffset = range.startOffset;
70
+ const upToCaret = (textNode.textContent ?? "").slice(0, caretOffset);
71
+ const match = /(^|\s)@([^\s@]{0,40})$/.exec(upToCaret);
72
+ if (!match) return setActiveToken(null);
73
+ const query = match[2];
74
+ setActiveToken({ query, textNode, atOffset: caretOffset - query.length - 1, caretOffset });
75
+ }, []);
76
+
77
+ /** Fired on input/keyup/click/select — refresh emptiness + token. */
78
+ const onEditorChange = useCallback(() => {
79
+ bump();
80
+ detectToken();
81
+ }, [bump, detectToken]);
82
+
83
+ /** Run a formatting command against the current selection. */
84
+ const format = useCallback(
85
+ (command: RichCommand) => {
86
+ const editor = editorRef.current;
87
+ if (!editor) return;
88
+ editor.focus();
89
+ document.execCommand(command);
90
+ onEditorChange();
91
+ },
92
+ [onEditorChange],
93
+ );
94
+
95
+ /** Prompt for a URL and wrap the current selection in a link. */
96
+ const insertLink = useCallback(
97
+ (url?: string) => {
98
+ const editor = editorRef.current;
99
+ if (!editor) return;
100
+ editor.focus();
101
+ const href = url ?? (typeof window !== "undefined" ? window.prompt("Link URL") ?? "" : "");
102
+ if (!href.trim()) return;
103
+ const normalized = /^https?:\/\//i.test(href) || href.startsWith("/") ? href : `https://${href}`;
104
+ document.execCommand("createLink", false, normalized);
105
+ onEditorChange();
106
+ },
107
+ [onEditorChange],
108
+ );
109
+
110
+ /** Replace the active @token with a non-editable mention chip. */
111
+ const insertMention = useCallback(
112
+ (member: MentionSuggestion) => {
113
+ const editor = editorRef.current;
114
+ const token = activeToken;
115
+ if (!editor || !token) return;
116
+ const doc = editor.ownerDocument;
117
+ const sel = doc.defaultView?.getSelection();
118
+ if (!sel) return;
119
+
120
+ // Select the "@query" span and replace it with a chip + trailing space.
121
+ const range = doc.createRange();
122
+ range.setStart(token.textNode, token.atOffset);
123
+ range.setEnd(token.textNode, token.caretOffset);
124
+ range.deleteContents();
125
+
126
+ const chip = doc.createElement("span");
127
+ chip.setAttribute("data-account-id", member.accountId);
128
+ chip.setAttribute("data-mention-name", member.name);
129
+ chip.setAttribute("contenteditable", "false");
130
+ chip.textContent = `@${member.name}`;
131
+
132
+ const trailing = doc.createTextNode(" ");
133
+ range.insertNode(trailing);
134
+ range.insertNode(chip);
135
+
136
+ // Move the caret after the trailing space.
137
+ const after = doc.createRange();
138
+ after.setStartAfter(trailing);
139
+ after.collapse(true);
140
+ sel.removeAllRanges();
141
+ sel.addRange(after);
142
+
143
+ setActiveToken(null);
144
+ bump();
145
+ },
146
+ [activeToken, bump],
147
+ );
148
+
149
+ const getBody = useCallback(() => {
150
+ const editor = editorRef.current;
151
+ if (!editor) return serializeRichToLexical(document.createElement("div"));
152
+ return serializeRichToLexical(editor);
153
+ }, []);
154
+
155
+ const getMentionedAccountIds = useCallback(() => {
156
+ const editor = editorRef.current;
157
+ if (!editor) return [];
158
+ return collectMentions(editor).map((m) => m.accountId);
159
+ }, []);
160
+
161
+ const isEmpty = useMemo(() => {
162
+ // `tick` is the recompute trigger: it bumps on every DOM mutation.
163
+ void tick;
164
+ const editor = editorRef.current;
165
+ if (!editor) return true;
166
+ return isRichEmpty(editor);
167
+ }, [tick]);
168
+
169
+ const reset = useCallback(() => {
170
+ const editor = editorRef.current;
171
+ if (editor) editor.innerHTML = "";
172
+ setActiveToken(null);
173
+ bump();
174
+ }, [bump]);
175
+
176
+ return {
177
+ editorRef,
178
+ onEditorChange,
179
+ // Formatting
180
+ format,
181
+ insertLink,
182
+ // Autocomplete
183
+ suggestions,
184
+ isSuggesting: !!activeToken && suggestions.length > 0,
185
+ insertMention,
186
+ dismissSuggestions: () => setActiveToken(null),
187
+ // Submission / state
188
+ isEmpty,
189
+ getBody,
190
+ getMentionedAccountIds,
191
+ reset,
192
+ };
193
+ }
@@ -0,0 +1,81 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import {
3
+ type CookieConsent,
4
+ getCookieConsent,
5
+ saveCookieConsent,
6
+ clearCookieConsent,
7
+ onCookieConsentChange,
8
+ DENY_ALL,
9
+ GRANT_ALL,
10
+ } from "../../../utils/cookieConsent";
11
+
12
+ export interface UseCookieConsentResult {
13
+ /** The current decision, or `null` if the visitor hasn't chosen yet. */
14
+ consent: CookieConsent | null;
15
+ /** False until the stored choice has been read from storage after mount. Gate
16
+ * an auto-showing banner on this so it doesn't flash for a returning visitor
17
+ * who already decided (whose decision only loads post-mount). */
18
+ ready: boolean;
19
+ /** True once a decision exists (used to show/hide a banner). */
20
+ decided: boolean;
21
+ /** Convenience booleans (false until decided). */
22
+ performance: boolean;
23
+ marketing: boolean;
24
+ /** Grant every category. */
25
+ acceptAll: () => void;
26
+ /** Reject everything but strictly-necessary. */
27
+ rejectAll: () => void;
28
+ /** Persist a specific set of choices (functional is forced on). */
29
+ save: (consent: Partial<CookieConsent>) => void;
30
+ /** Wipe the decision so the banner shows again (e.g. a "Cookie settings" link). */
31
+ reopen: () => void;
32
+ }
33
+
34
+ /**
35
+ * Headless cookie-consent state. Reads the shared consent store and stays in
36
+ * sync across components and tabs, so a banner and the analytics layer agree
37
+ * without prop-drilling. This is the primitive behind `<CookieConsent />` and
38
+ * the gate `<ForgeAnalytics />` uses — a creator can build their own banner on
39
+ * top of it.
40
+ */
41
+ export function useCookieConsent(): UseCookieConsentResult {
42
+ // Always start `null` so the first client render matches the server (which has
43
+ // no `localStorage`). Reading storage during render would desync SSR ↔ client
44
+ // and abort hydration — leaving the banner visible but with no event handlers
45
+ // (dead toggles/buttons). Populate from storage after mount instead.
46
+ const [consent, setConsent] = useState<CookieConsent | null>(null);
47
+ const [ready, setReady] = useState(false);
48
+
49
+ useEffect(() => {
50
+ setConsent(getCookieConsent());
51
+ setReady(true);
52
+ return onCookieConsentChange(() => setConsent(getCookieConsent()));
53
+ }, []);
54
+
55
+ const acceptAll = useCallback(() => saveCookieConsent(GRANT_ALL), []);
56
+ const rejectAll = useCallback(() => saveCookieConsent(DENY_ALL), []);
57
+ const save = useCallback(
58
+ (next: Partial<CookieConsent>) =>
59
+ saveCookieConsent({
60
+ functional: true,
61
+ performance: !!next.performance,
62
+ marketing: !!next.marketing,
63
+ }),
64
+ [],
65
+ );
66
+ // Broadcast so a `<CookieConsent/>` rendered elsewhere (e.g. TribeNestApp) re-opens
67
+ // — the event handler above re-reads the (now-empty) store for every instance.
68
+ const reopen = useCallback(() => clearCookieConsent(), []);
69
+
70
+ return {
71
+ consent,
72
+ ready,
73
+ decided: consent !== null,
74
+ performance: consent?.performance ?? false,
75
+ marketing: consent?.marketing ?? false,
76
+ acceptAll,
77
+ rejectAll,
78
+ save,
79
+ reopen,
80
+ };
81
+ }
@@ -0,0 +1,99 @@
1
+ import { useState } from "react";
2
+ import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
+ import { useGetCourse, useCreateCourseBooking } from "../../../data/queries/useCourses";
4
+ import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
5
+ import { readAttributionRef } from "../../../utils/attribution";
6
+ import { readLanding } from "../../../utils/landing";
7
+
8
+ export type CourseCheckoutStep = "details" | "payment";
9
+
10
+ export interface UseCourseCheckoutOptions {
11
+ /** Stripe return URL for a PAID purchase (its `return_url`). Default
12
+ * `/i/courses/:slug/finalise`. A real URL is required for the redirect leg. */
13
+ finalisePath?: (slug: string, bookingId: string) => string;
14
+ /** Called when a FREE enrollment completes in-app — the host navigates however
15
+ * it wants, instead of Forge doing a `window.location` redirect. */
16
+ onComplete?: (info: { slug: string; bookingId: string }) => void;
17
+ }
18
+
19
+ const errMessage = (e: unknown) =>
20
+ (e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Something went wrong.";
21
+
22
+ /**
23
+ * Headless course purchase: buyer details → payment. Composes `useGetCourse`,
24
+ * `useCreateCourseBooking`, `usePaymentFlow`. Free courses skip payment and
25
+ * redirect to the finalise page. Finalize via `useCourseBookingFinalize`.
26
+ */
27
+ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions = {}) {
28
+ const { user } = usePublicAuth();
29
+ // Detail by slug; the booking is created against the real course id.
30
+ const { data: course, isLoading } = useGetCourse(slug);
31
+ const courseId = course?.id;
32
+ const createBooking = useCreateCourseBooking(courseId);
33
+ const flow = usePaymentFlow({ path: `/public/courses/${courseId}/start-payment`, autoStart: false });
34
+
35
+ const [step, setStep] = useState<CourseCheckoutStep>("details");
36
+ const [firstName, setFirstName] = useState(user?.firstName ?? "");
37
+ const [lastName, setLastName] = useState(user?.lastName ?? "");
38
+ const [email, setEmail] = useState(user?.email ?? "");
39
+ const [questionnaire, setQuestionnaire] = useState<unknown>(undefined);
40
+ const [returnUrl, setReturnUrl] = useState("");
41
+ const [error, setError] = useState<string | null>(null);
42
+
43
+ const finalisePath =
44
+ opts.finalisePath ?? ((s, bookingId) => `/i/courses/${s}/finalise?bookingId=${bookingId}`);
45
+
46
+ const continueToPayment = async () => {
47
+ setError(null);
48
+ if (!slug || !courseId) return;
49
+ if (!firstName.trim() || !lastName.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
50
+ setError("Please enter your name and a valid email.");
51
+ return;
52
+ }
53
+ try {
54
+ const data = await createBooking.mutateAsync({
55
+ email,
56
+ firstName,
57
+ lastName,
58
+ questionnaire,
59
+ attributionRefId: readAttributionRef() ?? undefined,
60
+ ...(readLanding() ?? {}),
61
+ });
62
+ const origin = typeof window !== "undefined" ? window.location.origin : "";
63
+ const ru = `${origin}${finalisePath(slug, data.bookingId)}`;
64
+ setReturnUrl(ru);
65
+ if (data.isFree) {
66
+ if (opts.onComplete) opts.onComplete({ slug, bookingId: data.bookingId });
67
+ else if (typeof window !== "undefined") window.location.href = ru;
68
+ return;
69
+ }
70
+ const result = await flow.start({ bookingId: data.bookingId, returnUrl: ru });
71
+ if (result.provider && result.provider !== "stripe") return;
72
+ setStep("payment");
73
+ } catch (e) {
74
+ setError(errMessage(e));
75
+ }
76
+ };
77
+
78
+ return {
79
+ course,
80
+ isLoading,
81
+ step,
82
+ setStep,
83
+ firstName,
84
+ setFirstName,
85
+ lastName,
86
+ setLastName,
87
+ email,
88
+ setEmail,
89
+ questionnaire,
90
+ setQuestionnaire,
91
+ continueToPayment,
92
+ clientSecret: flow.clientSecret,
93
+ /** Authoritative sales-tax quote from start-payment (display only). */
94
+ taxQuote: flow.result?.taxQuote ?? null,
95
+ returnUrl,
96
+ isProcessing: createBooking.isPending || flow.isStarting,
97
+ error,
98
+ };
99
+ }