@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
@@ -2,6 +2,7 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tansta
2
2
  import { useEffect, useRef, useState } from "react";
3
3
  import { io, type Socket } from "socket.io-client";
4
4
  import { useForge } from "../../provider/ForgeProvider";
5
+ import { useCommunityScope } from "../../contexts/CommunityContext";
5
6
  import { getForgeWebSocketUrl } from "../../client/createForgeClient";
6
7
  import { PUBLIC_ACCESS_TOKEN_KEY } from "../../contexts/PublicAuthContext";
7
8
 
@@ -13,6 +14,11 @@ export interface ChatChannel {
13
14
  description: string | null;
14
15
  /** For DM channels, the counterpart participant (backend-enriched). */
15
16
  otherParticipant?: ChatDmPeer | null;
17
+ /**
18
+ * A DM's pair key. Null for the member and creator DM; `agent:...` for the
19
+ * member's DM with the community AI (see `isCommunityAgentDm`).
20
+ */
21
+ dmKey?: string | null;
16
22
  }
17
23
 
18
24
  export interface ChatAttachment {
@@ -26,7 +32,11 @@ export interface ChatMessage {
26
32
  id: string;
27
33
  channelId: string;
28
34
  content: string | null;
29
- senderAccountId: string;
35
+ /** Null when the community AI sent it (`senderAgentId` is then set). */
36
+ senderAccountId: string | null;
37
+ /** The community AI, when it sent this message. `senderFirstName` is then its name. */
38
+ senderAgentId?: string | null;
39
+ senderAgentAvatarUrl?: string | null;
30
40
  senderFirstName: string;
31
41
  senderLastName: string;
32
42
  isPinned: boolean;
@@ -67,83 +77,85 @@ export interface ChatMessagesPage {
67
77
  // Query keys are exported so the realtime layer (and creators) can patch the
68
78
  // same caches the data hooks populate.
69
79
  export const chatKeys = {
70
- channels: (profileId?: string) => ["public-chat-channels", profileId] as const,
80
+ // Channel lists, unread counts, DMs and the directory are per COMMUNITY: a
81
+ // profile runs several, and each has its own channels and members.
82
+ channels: (communityId?: string) => ["public-chat-channels", communityId] as const,
71
83
  messages: (channelId?: string) => ["public-chat-messages", channelId] as const,
72
84
  pinned: (channelId?: string) => ["public-chat-pinned", channelId] as const,
73
- unread: (profileId?: string) => ["public-chat-unread-counts", profileId] as const,
74
- dm: (profileId?: string) => ["public-chat-dm", profileId] as const,
85
+ unread: (communityId?: string) => ["public-chat-unread-counts", communityId] as const,
86
+ dm: (communityId?: string) => ["public-chat-dm", communityId] as const,
75
87
  replies: (parentMessageId?: string) => ["public-chat-replies", parentMessageId] as const,
76
- memberDirectory: (profileId?: string) => ["public-chat-member-directory", profileId] as const,
88
+ memberDirectory: (communityId?: string) => ["public-chat-member-directory", communityId] as const,
77
89
  };
78
90
 
79
91
  // ── Data hooks ───────────────────────────────────────────────────────────────
80
92
 
81
93
  /** Channels visible to the current member (GET /public/chat/channels). */
82
94
  export function useChatChannels() {
83
- const { client, profileId } = useForge();
95
+ const { client, profileId, communityId } = useCommunityScope("useChatChannels()");
84
96
  return useQuery<ChatChannel[]>({
85
- queryKey: chatKeys.channels(profileId),
97
+ queryKey: chatKeys.channels(communityId),
86
98
  queryFn: async () => {
87
- const res = await client.get("/public/chat/channels", { params: { profileId } });
99
+ const res = await client.get("/public/chat/channels", { params: { profileId, communityId } });
88
100
  return res.data;
89
101
  },
90
- enabled: !!profileId && !!client,
102
+ enabled: !!profileId && !!communityId && !!client,
91
103
  });
92
104
  }
93
105
 
94
106
  /** Paginated messages for a channel, newest first (GET /public/chat/channels/:id/messages). */
95
107
  export function useChatMessages(channelId?: string, limit = 50) {
96
- const { client, profileId } = useForge();
108
+ const { client, profileId, communityId } = useCommunityScope("useChatMessages()");
97
109
  return useInfiniteQuery<ChatMessagesPage>({
98
110
  queryKey: chatKeys.messages(channelId),
99
111
  queryFn: async ({ pageParam = 1 }) => {
100
112
  const res = await client.get(`/public/chat/channels/${channelId}/messages`, {
101
- params: { profileId, page: pageParam, limit },
113
+ params: { profileId, communityId, page: pageParam, limit },
102
114
  });
103
115
  return res.data;
104
116
  },
105
117
  initialPageParam: 1,
106
118
  getNextPageParam: (lastPage) =>
107
119
  lastPage.page * lastPage.limit < lastPage.total ? lastPage.page + 1 : undefined,
108
- enabled: !!channelId && !!client && !!profileId,
120
+ enabled: !!channelId && !!client && !!profileId && !!communityId,
109
121
  });
110
122
  }
111
123
 
112
124
  /** Pinned messages for a channel (GET /public/chat/channels/:id/pinned). */
113
125
  export function useChatPinnedMessages(channelId?: string) {
114
- const { client, profileId } = useForge();
126
+ const { client, profileId, communityId } = useCommunityScope("useChatPinnedMessages()");
115
127
  return useQuery<ChatMessage[]>({
116
128
  queryKey: chatKeys.pinned(channelId),
117
129
  queryFn: async () => {
118
- const res = await client.get(`/public/chat/channels/${channelId}/pinned`, { params: { profileId } });
130
+ const res = await client.get(`/public/chat/channels/${channelId}/pinned`, { params: { profileId, communityId } });
119
131
  return res.data;
120
132
  },
121
- enabled: !!channelId && !!client && !!profileId,
133
+ enabled: !!channelId && !!client && !!profileId && !!communityId,
122
134
  });
123
135
  }
124
136
 
125
137
  /** Per-channel unread counts, polled every 30s (GET /public/chat/unread-counts). */
126
138
  export function useChatUnreadCounts() {
127
- const { client, profileId } = useForge();
139
+ const { client, profileId, communityId } = useCommunityScope("useChatUnreadCounts()");
128
140
  return useQuery<ChatUnreadCount[]>({
129
- queryKey: chatKeys.unread(profileId),
141
+ queryKey: chatKeys.unread(communityId),
130
142
  queryFn: async () => {
131
- const res = await client.get("/public/chat/unread-counts", { params: { profileId } });
143
+ const res = await client.get("/public/chat/unread-counts", { params: { profileId, communityId } });
132
144
  return res.data;
133
145
  },
134
- enabled: !!profileId && !!client,
146
+ enabled: !!profileId && !!communityId && !!client,
135
147
  refetchInterval: 30000,
136
148
  });
137
149
  }
138
150
 
139
151
  /** Mark messages as read (POST /public/chat/read-receipts); invalidates unread counts. */
140
152
  export function useMarkChatRead() {
141
- const { client, profileId } = useForge();
153
+ const { client, profileId, communityId } = useCommunityScope("useMarkChatRead()");
142
154
  const queryClient = useQueryClient();
143
155
  return useMutation({
144
156
  mutationFn: async (messageIds: string[]) => {
145
157
  if (messageIds.length === 0) return;
146
- await client.post("/public/chat/read-receipts", { profileId, messageIds });
158
+ await client.post("/public/chat/read-receipts", { profileId, communityId, messageIds });
147
159
  },
148
160
  onSuccess: () => {
149
161
  queryClient.invalidateQueries({ queryKey: ["public-chat-unread-counts"] });
@@ -153,7 +165,7 @@ export function useMarkChatRead() {
153
165
 
154
166
  /** Send a message over REST (POST /public/chat/messages). Realtime path uses the socket. */
155
167
  export function useSendChatMessage() {
156
- const { client, profileId } = useForge();
168
+ const { client, profileId, communityId } = useCommunityScope("useSendChatMessage()");
157
169
  return useMutation({
158
170
  mutationFn: async (input: {
159
171
  channelId: string;
@@ -161,7 +173,7 @@ export function useSendChatMessage() {
161
173
  attachments?: ChatAttachment[];
162
174
  parentMessageId?: string;
163
175
  }) => {
164
- const res = await client.post("/public/chat/messages", { profileId, ...input });
176
+ const res = await client.post("/public/chat/messages", { profileId, communityId, ...input });
165
177
  return res.data as ChatMessage;
166
178
  },
167
179
  });
@@ -169,69 +181,69 @@ export function useSendChatMessage() {
169
181
 
170
182
  /** Paginated replies for a thread parent (GET /public/chat/messages/:id/replies), oldest first. */
171
183
  export function useChatMessageReplies(parentMessageId?: string, limit = 50) {
172
- const { client, profileId } = useForge();
184
+ const { client, profileId, communityId } = useCommunityScope("useChatMessageReplies()");
173
185
  return useInfiniteQuery<ChatMessagesPage>({
174
186
  queryKey: chatKeys.replies(parentMessageId),
175
187
  queryFn: async ({ pageParam = 1 }) => {
176
188
  const res = await client.get(`/public/chat/messages/${parentMessageId}/replies`, {
177
- params: { profileId, page: pageParam, limit },
189
+ params: { profileId, communityId, page: pageParam, limit },
178
190
  });
179
191
  return res.data;
180
192
  },
181
193
  initialPageParam: 1,
182
194
  getNextPageParam: (lastPage) =>
183
195
  lastPage.page * lastPage.limit < lastPage.total ? lastPage.page + 1 : undefined,
184
- enabled: !!parentMessageId && !!client && !!profileId,
196
+ enabled: !!parentMessageId && !!client && !!profileId && !!communityId,
185
197
  });
186
198
  }
187
199
 
188
200
  /** Same-tier members the current member may start a DM with (GET /public/chat/dm/directory). */
189
201
  export function useChatMemberDirectory() {
190
- const { client, profileId } = useForge();
202
+ const { client, profileId, communityId } = useCommunityScope("useChatMemberDirectory()");
191
203
  return useQuery<ChatDirectoryMember[]>({
192
- queryKey: chatKeys.memberDirectory(profileId),
204
+ queryKey: chatKeys.memberDirectory(communityId),
193
205
  queryFn: async () => {
194
- const res = await client.get("/public/chat/dm/directory", { params: { profileId } });
206
+ const res = await client.get("/public/chat/dm/directory", { params: { profileId, communityId } });
195
207
  return res.data;
196
208
  },
197
- enabled: !!profileId && !!client,
209
+ enabled: !!profileId && !!communityId && !!client,
198
210
  });
199
211
  }
200
212
 
201
213
  /** Open/create a DM with another member (POST /public/chat/dm/member). */
202
214
  export function useCreateMemberDm() {
203
- const { client, profileId } = useForge();
215
+ const { client, profileId, communityId } = useCommunityScope("useCreateMemberDm()");
204
216
  const queryClient = useQueryClient();
205
217
  return useMutation({
206
218
  mutationFn: async (targetAccountId: string) => {
207
- const res = await client.post("/public/chat/dm/member", { profileId, targetAccountId });
219
+ const res = await client.post("/public/chat/dm/member", { profileId, communityId, targetAccountId });
208
220
  return res.data as ChatChannel;
209
221
  },
210
222
  onSuccess: () => {
211
- queryClient.invalidateQueries({ queryKey: chatKeys.channels(profileId) });
223
+ queryClient.invalidateQueries({ queryKey: chatKeys.channels(communityId) });
212
224
  },
213
225
  });
214
226
  }
215
227
 
216
228
  /** The current member's DM channel, if any (GET /public/chat/dm). */
217
229
  export function useChatDm() {
218
- const { client, profileId } = useForge();
230
+ const { client, profileId, communityId } = useCommunityScope("useChatDm()");
219
231
  return useQuery<ChatChannel | null>({
220
- queryKey: chatKeys.dm(profileId),
232
+ queryKey: chatKeys.dm(communityId),
221
233
  queryFn: async () => {
222
- const res = await client.get("/public/chat/dm", { params: { profileId } });
234
+ const res = await client.get("/public/chat/dm", { params: { profileId, communityId } });
223
235
  return res.data;
224
236
  },
225
- enabled: !!profileId && !!client,
237
+ enabled: !!profileId && !!communityId && !!client,
226
238
  });
227
239
  }
228
240
 
229
241
  /** Open/create the member's DM channel (POST /public/chat/dm). */
230
242
  export function useCreateChatDm() {
231
- const { client, profileId } = useForge();
243
+ const { client, profileId, communityId } = useCommunityScope("useCreateChatDm()");
232
244
  return useMutation({
233
245
  mutationFn: async () => {
234
- const res = await client.post("/public/chat/dm", { profileId });
246
+ const res = await client.post("/public/chat/dm", { profileId, communityId });
235
247
  return res.data as ChatChannel;
236
248
  },
237
249
  });
@@ -259,7 +271,7 @@ export function useChatSocket(enabled: boolean) {
259
271
  const next = io(getForgeWebSocketUrl(apiUrl), { auth: { token }, transports: ["websocket"] });
260
272
  socketRef.current = next;
261
273
  setSocket(next);
262
- // Intentionally not disconnecting on unmount — mirrors the client app, where the
274
+ // Intentionally not disconnecting on unmount: mirrors the client app, where the
263
275
  // chat socket persists across channel selection. Callers manage room join/leave.
264
276
  }, [enabled, apiUrl]);
265
277
 
@@ -1,4 +1,5 @@
1
1
  import { useMutation } from "@tanstack/react-query";
2
+ import { readMetaSignals } from "../../utils/metaEvents";
2
3
  import { useForge } from "../../provider/ForgeProvider";
3
4
  import { bundleCouponRequestBody } from "../../ui/headless/checkout/bundleCoupon";
4
5
  import type { CartItem, TicketCartItem } from "../../contexts/CartContext";
@@ -133,7 +134,7 @@ export function useCreateCheckout() {
133
134
 
134
135
  return useMutation<CreateCheckoutResult, unknown, Record<string, unknown>>({
135
136
  mutationFn: async (body) => {
136
- const res = await client.post("/public/checkouts", { profileId, ...body });
137
+ const res = await client.post("/public/checkouts", { profileId, ...body, metaSignals: readMetaSignals() });
137
138
  return res.data;
138
139
  },
139
140
  });
@@ -1,4 +1,5 @@
1
1
  import type { BookingSlot, CancellationTermsView, PillarDiscountQuote } from "../../types/models";
2
+ import { fireServerMetaEvent, readMetaSignals, type ServerMetaEvent } from "../../utils/metaEvents";
2
3
  import { useForge } from "../../provider/ForgeProvider";
3
4
  import { useMutation, useQuery } from "@tanstack/react-query";
4
5
 
@@ -44,6 +45,7 @@ export function useReserveCoachingBooking(productId?: string) {
44
45
  mutationFn: async ({ slotId }) => {
45
46
  const res = await client.post(`/public/coaching/products/${productId}/booking/reserve`, {
46
47
  slotId,
48
+ metaSignals: readMetaSignals(),
47
49
  });
48
50
  return res.data;
49
51
  },
@@ -71,6 +73,8 @@ export type UpdateCoachingBookingInput = {
71
73
  export type UpdateCoachingBookingResult = PillarDiscountQuote & {
72
74
  bookingId: string;
73
75
  isConfirmed: boolean;
76
+ /** Set when this call newly confirmed a free booking and the server queued Schedule. */
77
+ metaEvent?: ServerMetaEvent;
74
78
  };
75
79
 
76
80
  /**
@@ -87,9 +91,14 @@ export function useUpdateCoachingBooking(productId?: string) {
87
91
 
88
92
  return useMutation<UpdateCoachingBookingResult, unknown, UpdateCoachingBookingInput>({
89
93
  mutationFn: async (body) => {
90
- const res = await client.post(`/public/coaching/products/${productId}/booking/update`, body);
94
+ const res = await client.post(`/public/coaching/products/${productId}/booking/update`, {
95
+ ...body,
96
+ metaSignals: readMetaSignals(),
97
+ });
91
98
  return res.data;
92
99
  },
100
+ // A free booking newly confirmed: the server queued Schedule; fire its pixel twin.
101
+ onSuccess: (data) => fireServerMetaEvent(data),
93
102
  });
94
103
  }
95
104
 
@@ -0,0 +1,345 @@
1
+ import { useContext } from "react";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { useForge } from "../../provider/ForgeProvider";
4
+ import { PublicAuthContext } from "../../contexts/PublicAuthContext";
5
+ import type { MembershipTier } from "../../types/models";
6
+
7
+ /**
8
+ * The public communities API (`/public/communities/*` and the fan's own
9
+ * memberships). Every creator can run several communities, each with its own
10
+ * tiers, members, feed, chat and calls, so a community is always NAMED by the
11
+ * caller: by slug in a URL, by id everywhere else.
12
+ *
13
+ * These are the raw data hooks. The step machines and state a page is built
14
+ * from live in `ui/headless/communities`, and the member-content hooks
15
+ * (spaces, feed, chat, calls, collections) read the community from
16
+ * `<CommunityProvider>` rather than taking it as an argument.
17
+ */
18
+
19
+ // ---- Types (mirror docs/proposals/communities-build-contract.md section 3.2) ----
20
+
21
+ /** A community as a visitor sees it. */
22
+ export interface PublicCommunity {
23
+ id: string;
24
+ name: string;
25
+ slug: string;
26
+ description: string | null;
27
+ coverUrl: string | null;
28
+ logoUrl: string | null;
29
+ category: string | null;
30
+ tags: string[];
31
+ memberCount: number;
32
+ /** How a visitor gets in. Absent on older servers: treat as open. */
33
+ joinMode?: "open" | "application" | "invite";
34
+ }
35
+
36
+ /** A tier the viewer may join: the public tier shape plus the community it belongs to. */
37
+ export type PublicTier = MembershipTier & { communityId: string };
38
+
39
+ /**
40
+ * Where the viewer stands in ONE community. Never read across communities: a
41
+ * member of community A is `none` in community B.
42
+ */
43
+ export type CommunityViewerStateName = "anonymous" | "none" | "pending" | "member" | "past_due" | "cancelled";
44
+
45
+ export interface CommunityViewerMembership {
46
+ id: string;
47
+ tierId: string;
48
+ tierName: string;
49
+ status: string;
50
+ cancelAtPeriodEnd: boolean;
51
+ currentPeriodEnd: string | null;
52
+ /** A downgrade waiting for the end of the paid period, or null. */
53
+ pendingDowngradeTierId: string | null;
54
+ }
55
+
56
+ export interface CommunityViewerApplication {
57
+ id: string;
58
+ tierId: string;
59
+ status: string;
60
+ createdAt: string;
61
+ }
62
+
63
+ export interface CommunityUpgradeTier {
64
+ id: string;
65
+ name: string;
66
+ priceMonthly: number | null;
67
+ priceYearly: number | null;
68
+ }
69
+
70
+ export interface CommunityViewerState {
71
+ state: CommunityViewerStateName;
72
+ membership: CommunityViewerMembership | null;
73
+ application: CommunityViewerApplication | null;
74
+ /** Tiers above the current one, in this community. Empty for a non-member. */
75
+ upgradeTiers: CommunityUpgradeTier[];
76
+ canInteract: boolean;
77
+ }
78
+
79
+ export type CommunityInviteKind = "email" | "link";
80
+ export type CommunityInviteStateName =
81
+ | "valid"
82
+ | "expired"
83
+ | "revoked"
84
+ | "used"
85
+ | "used_up"
86
+ | "email_mismatch"
87
+ | "not_found";
88
+
89
+ export interface CommunityInviteState {
90
+ kind: CommunityInviteKind;
91
+ state: CommunityInviteStateName;
92
+ /** The address a per-email invite was sent to. Null for a shareable link. */
93
+ invitedEmail: string | null;
94
+ /** The tier the invite opens, or null when the token is dead. */
95
+ tier: PublicTier | null;
96
+ }
97
+
98
+ /** `GET /public/communities/:slug` */
99
+ export interface CommunityDetail {
100
+ community: PublicCommunity;
101
+ tiers: PublicTier[];
102
+ viewer: CommunityViewerState;
103
+ invite: CommunityInviteState | null;
104
+ }
105
+
106
+ /** The two invite credentials a URL can carry. Only one is ever set. */
107
+ export interface CommunityTokens {
108
+ /** A per-email invite token (`?invite=`). */
109
+ invite?: string | null;
110
+ /** A shareable invite link token (`?join=`). */
111
+ join?: string | null;
112
+ }
113
+
114
+ export interface CommunityUpgradePreview {
115
+ amount: number;
116
+ currency: string;
117
+ interval: string;
118
+ /** True on Paystack: the upgrade collects the card again and replaces the subscription. */
119
+ requiresCard: boolean;
120
+ prorated: boolean;
121
+ }
122
+
123
+ export interface CommunityUpgradeResult {
124
+ status: "changed" | "requires_payment";
125
+ /** STRIPE: a PaymentIntent secret when the provider needs the card confirmed. */
126
+ clientSecret?: string;
127
+ /** PAYSTACK: the new subscription's inline checkout session. */
128
+ accessCode?: string;
129
+ /** PAYSTACK: hosted page fallback. */
130
+ checkoutUrl?: string;
131
+ membership: CommunityViewerMembership | null;
132
+ }
133
+
134
+ /** One entry of `GET /public/memberships/mine`: a membership in one community. */
135
+ export interface MyCommunityMembership {
136
+ id: string;
137
+ tierId?: string;
138
+ membershipTierId?: string;
139
+ tierName?: string;
140
+ status: string;
141
+ cancelAtPeriodEnd?: boolean;
142
+ currentPeriodEnd?: string | null;
143
+ endDate?: string | null;
144
+ pendingDowngradeTierId?: string | null;
145
+ subscriptionAmount?: number | null;
146
+ subscriptionCurrency?: string | null;
147
+ billingCycle?: string | null;
148
+ trialEndsAt?: string | null;
149
+ membershipTier?: { id: string; name: string } | null;
150
+ community: { id: string; name: string; slug: string };
151
+ }
152
+
153
+ /**
154
+ * The signed-in account, read softly: these hooks also run on a page with no
155
+ * auth provider (a static About page), where the viewer is simply anonymous.
156
+ */
157
+ export function useViewerAccountId(): string | null {
158
+ return useContext(PublicAuthContext)?.user?.id ?? null;
159
+ }
160
+
161
+ // ---- Keys ---------------------------------------------------------------------
162
+
163
+ export const communitiesKeys = {
164
+ all: ["public-communities"] as const,
165
+ list: (profileId?: string) => ["public-communities", "list", profileId] as const,
166
+ detail: (profileId: string | undefined, ref: string | undefined, tokens: CommunityTokens, accountId?: string | null) =>
167
+ ["public-communities", "detail", profileId, ref, tokens.invite ?? null, tokens.join ?? null, accountId ?? null] as const,
168
+ me: (profileId: string | undefined, ref: string | undefined, accountId?: string | null) =>
169
+ ["public-communities", "me", profileId, ref, accountId ?? null] as const,
170
+ invite: (profileId: string | undefined, ref: string | undefined, tokens: CommunityTokens, accountId?: string | null) =>
171
+ ["public-communities", "invite", profileId, ref, tokens.invite ?? null, tokens.join ?? null, accountId ?? null] as const,
172
+ upgradePreview: (profileId: string | undefined, ref: string | undefined, tierId: string | undefined) =>
173
+ ["public-communities", "upgrade-preview", profileId, ref, tierId ?? null] as const,
174
+ mine: (profileId?: string, accountId?: string | null) => ["public-communities", "mine", profileId, accountId ?? null] as const,
175
+ };
176
+
177
+ const tokenParams = (tokens: CommunityTokens) => ({
178
+ ...(tokens.invite ? { invite: tokens.invite } : {}),
179
+ ...(tokens.join ? { join: tokens.join } : {}),
180
+ });
181
+
182
+ const enc = (ref: string) => encodeURIComponent(ref);
183
+
184
+ // ---- Reads ----------------------------------------------------------------------
185
+
186
+ /** Every live community on the profile (`GET /public/communities`). */
187
+ export function usePublicCommunities(opts: { enabled?: boolean } = {}) {
188
+ const { client, profileId } = useForge();
189
+ return useQuery<PublicCommunity[]>({
190
+ queryKey: communitiesKeys.list(profileId),
191
+ queryFn: async () => (await client.get("/public/communities", { params: { profileId } })).data,
192
+ enabled: !!profileId && !!client && opts.enabled !== false,
193
+ });
194
+ }
195
+
196
+ /**
197
+ * One community with its joinable tiers, the viewer's state and, when the URL
198
+ * carried a token, the invite (`GET /public/communities/:slug`). `ref` is a
199
+ * slug or an id. The token is part of the key because it changes which tiers
200
+ * come back: an invite-only tier is listed only with a token that opens it.
201
+ */
202
+ export function usePublicCommunity(ref: string | undefined, tokens: CommunityTokens = {}, opts: { enabled?: boolean } = {}) {
203
+ const { client, profileId } = useForge();
204
+ const accountId = useViewerAccountId();
205
+ return useQuery<CommunityDetail>({
206
+ queryKey: communitiesKeys.detail(profileId, ref, tokens, accountId),
207
+ queryFn: async () =>
208
+ (await client.get(`/public/communities/${enc(ref!)}`, { params: { profileId, ...tokenParams(tokens) } })).data,
209
+ enabled: !!ref && !!profileId && !!client && opts.enabled !== false,
210
+ retry: (count, error) => !isNotFound(error) && count < 2,
211
+ });
212
+ }
213
+
214
+ /** The viewer's state in one community (`GET /public/communities/:slug/me`). Keyed per account. */
215
+ export function useCommunityViewerState(ref: string | undefined, opts: { enabled?: boolean } = {}) {
216
+ const { client, profileId } = useForge();
217
+ const accountId = useViewerAccountId();
218
+ return useQuery<CommunityViewerState>({
219
+ queryKey: communitiesKeys.me(profileId, ref, accountId),
220
+ queryFn: async () => (await client.get(`/public/communities/${enc(ref!)}/me`, { params: { profileId } })).data,
221
+ enabled: !!ref && !!profileId && !!client && opts.enabled !== false,
222
+ });
223
+ }
224
+
225
+ /**
226
+ * What an invite token is worth (`GET /public/communities/:slug/invite`).
227
+ * Keyed per account because `email_mismatch` depends on who is signed in.
228
+ */
229
+ export function useCommunityInviteState(ref: string | undefined, tokens: CommunityTokens) {
230
+ const { client, profileId } = useForge();
231
+ const accountId = useViewerAccountId();
232
+ const hasToken = !!(tokens.invite || tokens.join);
233
+ return useQuery<CommunityInviteState>({
234
+ queryKey: communitiesKeys.invite(profileId, ref, tokens, accountId),
235
+ queryFn: async () =>
236
+ (await client.get(`/public/communities/${enc(ref!)}/invite`, { params: { profileId, ...tokenParams(tokens) } })).data,
237
+ enabled: !!ref && hasToken && !!profileId && !!client,
238
+ retry: (count, error) => !isNotFound(error) && count < 2,
239
+ });
240
+ }
241
+
242
+ /** What moving to `membershipTierId` costs now (`GET /public/communities/:slug/upgrade-preview`). */
243
+ export function useCommunityUpgradePreview(ref: string | undefined, membershipTierId: string | undefined) {
244
+ const { client, profileId } = useForge();
245
+ return useQuery<CommunityUpgradePreview>({
246
+ queryKey: communitiesKeys.upgradePreview(profileId, ref, membershipTierId),
247
+ queryFn: async () =>
248
+ (
249
+ await client.get(`/public/communities/${enc(ref!)}/upgrade-preview`, {
250
+ params: { profileId, membershipTierId },
251
+ })
252
+ ).data,
253
+ enabled: !!ref && !!membershipTierId && !!profileId && !!client,
254
+ });
255
+ }
256
+
257
+ /** Every membership the signed-in fan holds with this creator, one per community. */
258
+ export function useMyCommunityMemberships(opts: { enabled?: boolean } = {}) {
259
+ const { client, profileId } = useForge();
260
+ const accountId = useViewerAccountId();
261
+ return useQuery<MyCommunityMembership[]>({
262
+ queryKey: communitiesKeys.mine(profileId, accountId),
263
+ // The server answers `[{ community, membership }]`; the hooks read one flat
264
+ // row per community, so the envelope is unwrapped here, in one place.
265
+ queryFn: async () => {
266
+ const rows = (await client.get("/public/memberships/mine", { params: { profileId } })).data as Array<{
267
+ community: MyCommunityMembership["community"];
268
+ membership: Record<string, unknown> & { amount?: number | null; currency?: string | null };
269
+ }>;
270
+ return rows.map(
271
+ ({ community, membership }) =>
272
+ ({
273
+ ...membership,
274
+ community,
275
+ subscriptionAmount: membership.amount ?? null,
276
+ subscriptionCurrency: membership.currency ?? null,
277
+ }) as unknown as MyCommunityMembership,
278
+ );
279
+ },
280
+ enabled: !!accountId && !!profileId && !!client && opts.enabled !== false,
281
+ });
282
+ }
283
+
284
+ // ---- Writes -----------------------------------------------------------------------
285
+
286
+ /** Every community read goes stale together after a membership change. */
287
+ export function useInvalidateCommunities() {
288
+ const queryClient = useQueryClient();
289
+ return () =>
290
+ Promise.all([
291
+ queryClient.invalidateQueries({ queryKey: communitiesKeys.all }),
292
+ queryClient.invalidateQueries({ queryKey: ["membership-tiers"] }),
293
+ queryClient.invalidateQueries({ queryKey: ["my-membership-applications"] }),
294
+ ]);
295
+ }
296
+
297
+ /** Move to a higher tier (`POST /public/communities/:slug/upgrade`). */
298
+ export function useUpgradeCommunityTier(ref: string | undefined) {
299
+ const { client, profileId } = useForge();
300
+ const invalidate = useInvalidateCommunities();
301
+ return useMutation<CommunityUpgradeResult, unknown, { membershipTierId: string; returnUrl?: string }>({
302
+ mutationFn: async (body) =>
303
+ (await client.post(`/public/communities/${enc(ref!)}/upgrade`, { profileId, ...body })).data,
304
+ onSuccess: (data) => {
305
+ if (data.status === "changed") void invalidate();
306
+ },
307
+ });
308
+ }
309
+
310
+ /** Cancel (`POST /public/communities/:slug/membership/cancel`). A paid period runs out first. */
311
+ export function useCancelCommunityMembership(ref: string | undefined) {
312
+ const { client, profileId } = useForge();
313
+ const invalidate = useInvalidateCommunities();
314
+ return useMutation<unknown, unknown, void>({
315
+ mutationFn: async () => (await client.post(`/public/communities/${enc(ref!)}/membership/cancel`, { profileId })).data,
316
+ onSuccess: () => void invalidate(),
317
+ });
318
+ }
319
+
320
+ /** Downgrade at the end of the paid period (`POST /public/communities/:slug/membership/downgrade`). */
321
+ export function useDowngradeCommunityMembership(ref: string | undefined) {
322
+ const { client, profileId } = useForge();
323
+ const invalidate = useInvalidateCommunities();
324
+ return useMutation<unknown, unknown, { membershipTierId: string }>({
325
+ mutationFn: async (body) =>
326
+ (await client.post(`/public/communities/${enc(ref!)}/membership/downgrade`, { profileId, ...body })).data,
327
+ onSuccess: () => void invalidate(),
328
+ });
329
+ }
330
+
331
+ /** Withdraw a pending downgrade (`POST /public/communities/:slug/membership/undo-downgrade`). */
332
+ export function useUndoCommunityDowngrade(ref: string | undefined) {
333
+ const { client, profileId } = useForge();
334
+ const invalidate = useInvalidateCommunities();
335
+ return useMutation<unknown, unknown, void>({
336
+ mutationFn: async () =>
337
+ (await client.post(`/public/communities/${enc(ref!)}/membership/undo-downgrade`, { profileId })).data,
338
+ onSuccess: () => void invalidate(),
339
+ });
340
+ }
341
+
342
+ /** True when an error is a 404: an answer (no such community, dead token), not a transient failure. */
343
+ export function isNotFound(error: unknown): boolean {
344
+ return (error as { response?: { status?: number } } | null)?.response?.status === 404;
345
+ }