@tribe-nest/forge 3.59.0 → 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/package.json +2 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CartContext.tsx +34 -1
  4. package/src/contexts/CommunityContext.tsx +192 -0
  5. package/src/contexts/PublicAuthContext.tsx +15 -3
  6. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  7. package/src/data/queries/useAccount.ts +33 -2
  8. package/src/data/queries/useAnalytics.ts +6 -6
  9. package/src/data/queries/useCallChat.ts +2 -2
  10. package/src/data/queries/useChat.ts +52 -40
  11. package/src/data/queries/useCheckouts.ts +2 -1
  12. package/src/data/queries/useCoachingAvailability.ts +10 -1
  13. package/src/data/queries/useCommunities.ts +345 -0
  14. package/src/data/queries/useCommunity.ts +450 -102
  15. package/src/data/queries/useCommunityAgent.ts +247 -0
  16. package/src/data/queries/useCommunityCalendar.ts +150 -0
  17. package/src/data/queries/useCommunityClassroom.ts +75 -0
  18. package/src/data/queries/useCommunityMembers.ts +108 -0
  19. package/src/data/queries/useCommunityModeration.ts +327 -0
  20. package/src/data/queries/useCommunityWiki.ts +137 -0
  21. package/src/data/queries/useCourses.ts +2 -0
  22. package/src/data/queries/useEmailSignIn.ts +79 -0
  23. package/src/data/queries/useEvents.ts +6 -1
  24. package/src/data/queries/useForms.ts +5 -1
  25. package/src/data/queries/useLeadMagnet.ts +6 -2
  26. package/src/data/queries/useMembership.ts +32 -7
  27. package/src/data/queries/useMembershipCalls.ts +116 -7
  28. package/src/data/queries/useMyBookings.ts +24 -1
  29. package/src/data/queries/useMyTickets.ts +24 -1
  30. package/src/data/queries/useNotifications.ts +6 -2
  31. package/src/data/queries/useOrders.ts +2 -1
  32. package/src/data/queries/usePWA.ts +111 -35
  33. package/src/data/queries/usePostCollections.ts +28 -16
  34. package/src/data/queries/usePosts.ts +16 -14
  35. package/src/data/queries/useSiteApp.ts +130 -0
  36. package/src/data/queries/useSubscriptions.ts +28 -9
  37. package/src/i18n/de.json +891 -2
  38. package/src/i18n/en.json +891 -2
  39. package/src/index.ts +36 -7
  40. package/src/runtime/pages.spec.ts +36 -0
  41. package/src/runtime/pages.ts +52 -27
  42. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  43. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  44. package/src/types/models.ts +11 -0
  45. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  46. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  47. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  48. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  49. package/src/ui/analytics/useMetaEvents.ts +78 -0
  50. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  51. package/src/ui/format/membershipGate.ts +19 -15
  52. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  53. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  54. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  55. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  56. package/src/ui/headless/_tests/communityHarness.tsx +212 -0
  57. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  58. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  59. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  60. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  61. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  62. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  63. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  64. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  65. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  66. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
  67. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
  68. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  69. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
  70. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  71. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  72. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  73. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  74. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  75. package/src/ui/headless/auth/useSignupForm.ts +30 -3
  76. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  77. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  78. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  79. package/src/ui/headless/communities/index.ts +96 -0
  80. package/src/ui/headless/communities/paths.ts +36 -0
  81. package/src/ui/headless/communities/useCommunity.ts +140 -0
  82. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  83. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  84. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  85. package/src/ui/headless/communities/useCommunityHome.ts +91 -0
  86. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  87. package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
  88. package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
  89. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  90. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  91. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  92. package/src/ui/headless/community/communityPoll.ts +297 -0
  93. package/src/ui/headless/community/countries.ts +123 -0
  94. package/src/ui/headless/community/index.ts +81 -1
  95. package/src/ui/headless/community/linkPreview.ts +71 -0
  96. package/src/ui/headless/community/richLexical.ts +27 -2
  97. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  98. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  99. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  100. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  101. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  102. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  103. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  104. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  105. package/src/ui/headless/downloads/index.ts +8 -0
  106. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  107. package/src/ui/headless/index.ts +21 -4
  108. package/src/ui/headless/memberHome/index.ts +11 -1
  109. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  110. package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
  111. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  112. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  113. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  114. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  115. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  116. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  117. package/src/ui/headless/portal/index.ts +57 -0
  118. package/src/ui/headless/portal/usePortal.ts +274 -0
  119. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  120. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  121. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  122. package/src/ui/headless/posts/upsell.ts +54 -8
  123. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  124. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  125. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  126. package/src/ui/headless/posts/usePostItem.ts +6 -5
  127. package/src/ui/headless/siteApp/appBar.ts +102 -0
  128. package/src/ui/headless/siteApp/appHome.ts +344 -0
  129. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  130. package/src/ui/headless/siteApp/index.ts +58 -0
  131. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  132. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  133. package/src/ui/headless/work/index.ts +2 -0
  134. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  135. package/src/ui/index.ts +28 -12
  136. package/src/ui/styled/AccountDashboard.tsx +754 -602
  137. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  138. package/src/ui/styled/ChatRoom.tsx +126 -16
  139. package/src/ui/styled/Checkout.tsx +8 -0
  140. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  141. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  142. package/src/ui/styled/CoachingDetail.tsx +2 -0
  143. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  144. package/src/ui/styled/CourseDetail.tsx +2 -0
  145. package/src/ui/styled/DownloadPage.tsx +263 -0
  146. package/src/ui/styled/EventConfirmation.tsx +10 -0
  147. package/src/ui/styled/EventDetail.tsx +10 -0
  148. package/src/ui/styled/FilmLibrary.tsx +22 -9
  149. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  150. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  151. package/src/ui/styled/MembershipTiers.tsx +49 -11
  152. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  153. package/src/ui/styled/Paywall.tsx +1 -1
  154. package/src/ui/styled/ProductDetail.tsx +12 -0
  155. package/src/ui/styled/PwaRegistration.tsx +5 -1
  156. package/src/ui/styled/SignupForm.tsx +18 -3
  157. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
  158. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  159. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  160. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  161. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  162. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  163. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  164. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  165. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  166. package/src/ui/styled/app/AppBar.tsx +298 -0
  167. package/src/ui/styled/app/AppHome.tsx +1139 -0
  168. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  169. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  170. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  171. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  172. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  173. package/src/ui/styled/app/index.ts +7 -0
  174. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  175. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  176. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  177. package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
  178. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  179. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  180. package/src/ui/styled/communities/CommunityHome.tsx +298 -0
  181. package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
  182. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
  183. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  184. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  185. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  186. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  187. package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
  188. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  189. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  190. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  191. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  192. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  193. package/src/ui/styled/communities/index.ts +19 -0
  194. package/src/ui/styled/communities/shared.tsx +177 -0
  195. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  196. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  197. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  198. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  199. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  200. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  201. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  202. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  203. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  204. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  205. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  206. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  207. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  208. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  209. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  210. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  211. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  212. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  213. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  214. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  215. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  216. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  217. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  218. package/src/ui/styled/community/index.ts +37 -2
  219. package/src/ui/styled/forge-utilities.css +253 -0
  220. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  221. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  222. package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
  223. package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
  224. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  225. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  226. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  227. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  228. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  229. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  230. package/src/ui/styled/members/index.ts +8 -0
  231. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  232. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  233. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  234. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  235. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  236. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  237. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  238. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  239. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  240. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  241. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  242. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  243. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  244. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  245. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  246. package/src/ui/styled/portal/index.ts +13 -0
  247. package/src/ui/styled/portal/portalNav.ts +260 -0
  248. package/src/ui/styled/portal/shared.tsx +206 -0
  249. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  250. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  251. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  252. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  253. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  254. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  255. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  256. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  257. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  258. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  259. package/src/ui/styled/work/index.ts +1 -1
  260. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  261. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  262. package/src/utils/landing.ts +19 -6
  263. package/src/utils/metaEvents.ts +302 -0
  264. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  265. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  266. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
@@ -1,5 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
+ import { useOptionalCommunityContext } from "../../../contexts/CommunityContext";
4
+ import { communityHomePath, communitiesListPath } from "../communities/paths";
3
5
  import {
4
6
  readMembershipInviteFromUrl,
5
7
  useApplyForMembership,
@@ -55,9 +57,13 @@ export type { BillingCycle };
55
57
  export interface UseMembershipCheckoutOptions {
56
58
  /** Pre-select a tier (e.g. from a `?membershipTierId=` param). */
57
59
  initialTierId?: string;
58
- /** Where the provider returns the member after a paid subscription. Default `/i/account?tab=membership&confirmSubscription=true`. */
60
+ /**
61
+ * Where the provider returns the member after a paid subscription. Default:
62
+ * the community home with `confirmSubscription=1` (the community comes from
63
+ * `<CommunityProvider>`), or `/i/communities` outside one. Never the account page.
64
+ */
59
65
  returnPath?: string;
60
- /** Where free activations land. Default `/i/account?tab=membership`. */
66
+ /** Where free activations land. Default: the community home with `welcome=1`. */
61
67
  freeReturnPath?: string;
62
68
  /**
63
69
  * Called when the subscription completes IN-APP with nothing left to pay: a
@@ -73,13 +79,30 @@ export interface UseMembershipCheckoutOptions {
73
79
  * the current url is used; `null` sends none.
74
80
  */
75
81
  inviteToken?: string | null;
82
+ /**
83
+ * A shareable invite link token (`?join=`), sent with every join call. It
84
+ * opens an invite-only tier; a paid tier still charges.
85
+ */
86
+ joinToken?: string | null;
76
87
  /**
77
88
  * Where Stripe returns after a redirect-based card confirmation on an
78
- * APPLICATION (a card that needed a bank redirect). Default
79
- * `/i/account?tab=membership`, where the fan's applications are listed. The
80
- * application id is appended as `confirmApplication`.
89
+ * APPLICATION (a card that needed a bank redirect). Default: the community
90
+ * home, where a pending application is shown. The application id is appended
91
+ * as `confirmApplication`.
81
92
  */
82
93
  applicationReturnPath?: string;
94
+ /**
95
+ * The tiers to offer, when the caller already has them (the community read
96
+ * returns exactly the tiers this viewer may join). Omitted, they are fetched.
97
+ */
98
+ tiers?: MembershipTier[];
99
+ /**
100
+ * The viewer's current membership IN THIS COMMUNITY, when the caller knows it.
101
+ * Decides whether subscribing is a tier change. Omitted, the signed-in
102
+ * account's single legacy `membership` is read, which knows nothing of
103
+ * communities, so a community surface always passes this.
104
+ */
105
+ current?: { tierId: string; status: string } | null;
83
106
  }
84
107
 
85
108
  const errMessage = (e: unknown) =>
@@ -106,8 +129,17 @@ const errMessage = (e: unknown) =>
106
129
  */
107
130
  export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
108
131
  const { user, refetchUser } = usePublicAuth();
132
+ const community = useOptionalCommunityContext();
109
133
  const inviteToken = opts.inviteToken === undefined ? readMembershipInviteFromUrl() : (opts.inviteToken ?? undefined);
110
- const { data: tiers, isLoading } = useGetMembershipTiers({ inviteToken: inviteToken ?? null });
134
+ const joinToken = opts.joinToken ?? undefined;
135
+ const fetched = useGetMembershipTiers({ inviteToken: inviteToken ?? null, enabled: !opts.tiers });
136
+ const tiers = opts.tiers ?? fetched.data;
137
+ const isLoading = opts.tiers ? false : fetched.isLoading;
138
+ /** The landing paths, inside the community whenever there is one. */
139
+ const homeFor = (query?: { welcome?: 1; confirmSubscription?: 1 }) =>
140
+ community?.slug ? communityHomePath(community.slug, query) : communitiesListPath();
141
+ const paidReturnPath = opts.returnPath ?? homeFor({ confirmSubscription: 1 });
142
+ const freeReturnPath = opts.freeReturnPath ?? homeFor({ welcome: 1 });
111
143
  const createPaid = useCreateSubscription();
112
144
  const createFree = useCreateFreeSubscription();
113
145
  const confirmLatest = useConfirmLatestSubscription();
@@ -224,16 +256,29 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
224
256
  [selectedTier, billingCycle, customAmount],
225
257
  );
226
258
 
259
+ /**
260
+ * The membership this checkout acts on. A fan holds one membership PER
261
+ * community, so inside a `CommunityProvider` only the caller's `current` (the
262
+ * viewer's membership in THAT community, as `useCommunityJoin` and the styled
263
+ * `MembershipCheckout` pass it) may name one. Without it the fan is treated as
264
+ * holding none here: `user.membership` is the newest membership on the whole
265
+ * profile and may belong to another community, where it would turn this join
266
+ * into a tier change. The server decides per community either way.
267
+ * `user.membership` remains the fallback only outside any community.
268
+ */
269
+ const current = opts.current !== undefined ? opts.current : community?.communityId ? null : undefined;
227
270
  const membership = user?.membership;
228
271
  /** The tier they are on now, so its card can say so instead of offering itself. */
229
- const currentTierId = membership?.membershipTierId;
272
+ const currentTierId = current !== undefined ? (current?.tierId ?? undefined) : membership?.membershipTierId;
230
273
  /**
231
274
  * A member with a live provider subscription picking a PAID tier is changing,
232
275
  * not joining. The server settles it by proration on the subscription they
233
276
  * already have, so the button says so.
234
277
  */
235
278
  const isChange =
236
- !!membership?.paymentProviderSubscriptionId && membership.status === "active" && !isFreeCycle && !!selectedTier;
279
+ current !== undefined
280
+ ? !!current && current.status === "active" && !isFreeCycle && !!selectedTier
281
+ : !!membership?.paymentProviderSubscriptionId && membership.status === "active" && !isFreeCycle && !!selectedTier;
237
282
 
238
283
  /**
239
284
  * The free trial this fan would start on the selected tier, in days, or 0.
@@ -264,9 +309,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
264
309
  }
265
310
  if (typeof window !== "undefined") {
266
311
  const origin = window.location.origin;
267
- const path = requiresConfirmation
268
- ? (opts.returnPath ?? "/i/account?tab=membership&confirmSubscription=true")
269
- : (opts.freeReturnPath ?? "/i/account?tab=membership");
312
+ const path = requiresConfirmation ? paidReturnPath : freeReturnPath;
270
313
  window.location.href = `${origin}${path}`;
271
314
  }
272
315
  };
@@ -275,7 +318,26 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
275
318
  const questionnaireBody = () =>
276
319
  questionnaire.length ? { questionnaire: buildQuestionnaireAnswers(questionnaire, questionnaireAnswers) } : {};
277
320
 
278
- const subscribe = async () => {
321
+ /**
322
+ * How many join calls are running. `isProcessing` reads THIS, not the
323
+ * mutations' `isPending`: `subscribe` is started from an effect by the join
324
+ * sheet, and a mutation started from an effect loses its observer when React
325
+ * StrictMode unmounts and remounts in development, leaving `isPending` stuck
326
+ * true after the request answered. A settled promise always clears this.
327
+ */
328
+ const [inFlight, setInFlight] = useState(0);
329
+ const tracked =
330
+ <A extends unknown[]>(run: (...args: A) => Promise<void>) =>
331
+ async (...args: A) => {
332
+ setInFlight((n) => n + 1);
333
+ try {
334
+ await run(...args);
335
+ } finally {
336
+ setInFlight((n) => n - 1);
337
+ }
338
+ };
339
+
340
+ const subscribe = tracked(async () => {
279
341
  setError(null);
280
342
  setAmountRefusal(null);
281
343
  setTrial(null);
@@ -302,10 +364,10 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
302
364
  await submitApplication(origin);
303
365
  return;
304
366
  }
305
- const returnUrl = `${origin}${opts.returnPath ?? "/i/account?tab=membership&confirmSubscription=true"}`;
367
+ const returnUrl = `${origin}${paidReturnPath}`;
306
368
  const attributionRefId = readAttributionRef() ?? undefined;
307
369
  const landing = readLanding() ?? {};
308
- const invite = inviteToken ? { inviteToken } : {};
370
+ const invite = { ...(inviteToken ? { inviteToken } : {}), ...(joinToken ? { joinToken } : {}) };
309
371
  const answers = questionnaireBody();
310
372
  try {
311
373
  if (isFreeCycle) {
@@ -371,7 +433,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
371
433
  } catch (e) {
372
434
  setError(errMessage(e));
373
435
  }
374
- };
436
+ });
375
437
 
376
438
  /**
377
439
  * Apply to an `application` tier. Free: the application is lodged and the
@@ -379,14 +441,16 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
379
441
  * moves to "payment" and the renderer saves the card; `confirmApplication()`
380
442
  * runs after it succeeds. Nothing is charged until the business approves.
381
443
  */
382
- const applicationReturnBase = opts.applicationReturnPath ?? "/i/account?tab=membership";
383
- const submitApplication = async (origin: string) => {
444
+ const applicationReturnBase = opts.applicationReturnPath ?? homeFor();
445
+ const submitApplication = tracked(async (origin: string) => {
384
446
  if (!selectedTier) return;
385
447
  try {
386
448
  const data = await applyMutation.mutateAsync({
387
449
  membershipTierId: selectedTier.id,
388
450
  ...(isFreeCycle ? {} : { billingCycle, amount }),
389
451
  ...questionnaireBody(),
452
+ ...(inviteToken ? { inviteToken } : {}),
453
+ ...(joinToken ? { joinToken } : {}),
390
454
  returnUrl: `${origin}${applicationReturnBase}`,
391
455
  });
392
456
  setApplication(data.application);
@@ -398,10 +462,10 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
398
462
  } catch (e) {
399
463
  setError(errMessage(e));
400
464
  }
401
- };
465
+ });
402
466
 
403
467
  /** Call after the card renderer confirmed the SetupIntent. Idempotent on the server. */
404
- const confirmApplication = async () => {
468
+ const confirmApplication = tracked(async () => {
405
469
  if (!application) return;
406
470
  setError(null);
407
471
  setCardConfirmed(true);
@@ -411,7 +475,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
411
475
  } catch (e) {
412
476
  setError(errMessage(e));
413
477
  }
414
- };
478
+ });
415
479
 
416
480
  const applicationOutcome: MembershipApplicationOutcome | null = membershipApplicationOutcome(
417
481
  application,
@@ -478,8 +542,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
478
542
  onError: (message) => setError(message ?? "The payment could not be completed. Please try again."),
479
543
  })
480
544
  : Promise.resolve("unavailable" as PaystackCheckoutOutcome),
481
- isProcessing:
482
- createPaid.isPending || createFree.isPending || applyMutation.isPending || confirmApplicationMutation.isPending,
545
+ isProcessing: inFlight > 0,
483
546
  error,
484
547
  /** Call on the return page to reconcile the latest subscription. */
485
548
  confirmLatest,
@@ -487,6 +550,10 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
487
550
  finish,
488
551
  /** The invite token this checkout forwards, if any. */
489
552
  inviteToken,
553
+ /** The shareable link token this checkout forwards, if any. */
554
+ joinToken,
555
+ /** Where a paid subscription returns: the community home with `confirmSubscription=1` by default. */
556
+ returnPath: paidReturnPath,
490
557
  /** True when subscribe() applies to the tier instead of joining it. */
491
558
  isApplication,
492
559
  /**
@@ -1,6 +1,8 @@
1
1
  import { useMemo } from "react";
2
2
  import type { PublicMembershipGate } from "../../../types/models";
3
3
  import { useGetMembershipTiers } from "../../../data/queries/useMembership";
4
+ import { usePublicCommunities } from "../../../data/queries/useCommunities";
5
+ import { communityAboutPath } from "../communities/paths";
4
6
  import {
5
7
  buildMembershipGateNotice,
6
8
  isMembershipGateLocked,
@@ -13,11 +15,15 @@ export interface UseMembershipGateNoticeInput {
13
15
  gate?: PublicMembershipGate | null;
14
16
  /** A `MEMBERSHIP_GATE` refusal from a failed purchase. Wins over `gate`. */
15
17
  refusal?: MembershipGateRefusal | null;
16
- /** What the thing is, in the buyer's words — "This ticket", "This course". */
18
+ /** What the thing is, in the buyer's words: "This ticket", "This course". */
17
19
  itemLabel?: string;
18
20
  /** Where a signed-out visitor signs in. Code sites mount theirs under `/i`. */
19
21
  loginPath?: string;
20
- /** Where a signed-in non-member goes to join — the tier listing. */
22
+ /**
23
+ * Where a signed-in non-member goes to join. Default: the About page of the
24
+ * community the first required tier belongs to, with that tier preselected
25
+ * (`/i/communities/<slug>?tier=<id>`), which joins in place.
26
+ */
21
27
  membershipPath?: string;
22
28
  }
23
29
 
@@ -29,14 +35,14 @@ export interface UseMembershipGateNoticeInput {
29
35
  * A gate comes down as `requiredTierIds`, and "you need tier 8f3c-…" is not a
30
36
  * sentence anyone can act on. The public tier list is already loaded by every
31
37
  * storefront that sells memberships, is cached by React Query under one key per
32
- * profile, and — unlike an id embedded in a ticket payload — stays correct when
38
+ * profile, and (unlike an id embedded in a ticket payload) stays correct when
33
39
  * the artist renames a tier. So the ids are resolved against it here, once, for
34
40
  * every surface.
35
41
  *
36
42
  * The names are best-effort by design: the query can be in flight, and a gating
37
43
  * tier can be one the public list does not carry (an archived or unlisted tier
38
44
  * still gates). `buildMembershipGateNotice` therefore degrades to "is for
39
- * members" rather than showing an id or rendering nothing — a badge with slightly
45
+ * members" rather than showing an id or rendering nothing: a badge with slightly
40
46
  * vaguer wording is a working funnel; a blank space is the bug this closes.
41
47
  *
42
48
  * Returns `null` when nothing is gated, which is also what every surface sees
@@ -44,22 +50,33 @@ export interface UseMembershipGateNoticeInput {
44
50
  */
45
51
  export function useMembershipGateNotice(input: UseMembershipGateNoticeInput): MembershipGateNotice | null {
46
52
  const locked = isMembershipGateLocked(input.gate) || !!input.refusal;
47
- // Only pulled once something is actually locked — an ungated storefront must
53
+ // Only pulled once something is actually locked: an ungated storefront must
48
54
  // not acquire a request it never needed.
49
- const { data: tiers } = useGetMembershipTiers({ enabled: locked });
55
+ // Profile-wide on purpose: a gate on a product can name a tier from any community.
56
+ const { data: tiers } = useGetMembershipTiers({ enabled: locked, communityId: null });
57
+ const { data: communities } = usePublicCommunities({ enabled: locked && !input.membershipPath });
50
58
 
51
59
  const requiredTierIds = input.refusal?.requiredTierIds ?? input.gate?.requiredTierIds ?? [];
52
60
 
53
61
  return useMemo(() => {
54
62
  if (!locked) return null;
55
63
  const byId = new Map((tiers ?? []).map((tier) => [tier.id, tier.name]));
64
+ // A tier belongs to exactly one community: its About page is where to join it.
65
+ const firstTier = (tiers ?? []).find((tier) => tier.id === requiredTierIds[0]) as
66
+ | { id: string; communityId?: string }
67
+ | undefined;
68
+ const community = firstTier?.communityId
69
+ ? communities?.find((candidate) => candidate.id === firstTier.communityId)
70
+ : undefined;
71
+ const membershipPath =
72
+ input.membershipPath ?? (community ? communityAboutPath(community.slug, { tier: firstTier!.id }) : undefined);
56
73
  return buildMembershipGateNotice({
57
74
  gate: input.gate,
58
75
  refusal: input.refusal,
59
76
  tierNames: requiredTierIds.map((id) => byId.get(id) ?? "").filter(Boolean),
60
77
  itemLabel: input.itemLabel,
61
78
  loginPath: input.loginPath,
62
- membershipPath: input.membershipPath,
79
+ membershipPath,
63
80
  currentPath: currentPath(),
64
81
  });
65
82
  // `requiredTierIds` is a fresh array each render; its CONTENTS are the input.
@@ -67,6 +84,7 @@ export function useMembershipGateNotice(input: UseMembershipGateNoticeInput): Me
67
84
  }, [
68
85
  locked,
69
86
  tiers,
87
+ communities,
70
88
  requiredTierIds.join(","),
71
89
  input.gate?.reason,
72
90
  input.refusal?.reason,
@@ -0,0 +1,57 @@
1
+ // Client portal (`/i/account/*`) headless primitives.
2
+ export {
3
+ usePortalSummary,
4
+ usePortalInvoices,
5
+ usePortalDocuments,
6
+ usePortalActivity,
7
+ useWorkApprovalRequests,
8
+ useDecideApproval,
9
+ type PortalSectionKey,
10
+ type PortalAttentionItem,
11
+ type PortalSummary,
12
+ type PortalInvoice,
13
+ type PortalDocument,
14
+ type PortalActivityKind,
15
+ type PortalActivityItem,
16
+ type PortalActivityPage,
17
+ type ApprovalStatus,
18
+ type WorkApprovalRequest,
19
+ type WorkApprovalRequests,
20
+ type ApprovalDecision,
21
+ } from "./usePortal";
22
+ export {
23
+ usePortalFiles,
24
+ portalFilesKey,
25
+ type PortalFile,
26
+ type PortalFileOpen,
27
+ type PortalFilesPage,
28
+ type PortalFileSource,
29
+ type PortalFileSourceFilter,
30
+ } from "./usePortalFiles";
31
+ // Preview as client: redeem the one-time code, and read whether this is a preview.
32
+ export {
33
+ usePortalPreview,
34
+ usePortalPreviewRedeem,
35
+ isPortalPreviewPending,
36
+ type PortalPreview,
37
+ type PortalPreviewRedeemState,
38
+ } from "./usePortalPreview";
39
+ // Messages: the one conversation with the creator.
40
+ export {
41
+ usePortalThread,
42
+ usePortalMessages,
43
+ useSendPortalMessage,
44
+ useMarkPortalMessagesRead,
45
+ usePortalMessageAttachments,
46
+ usePortalMessagesLive,
47
+ portalMessagesInOrder,
48
+ portalThreadKey,
49
+ portalMessagesKey,
50
+ PORTAL_MESSAGES_POLL_MS,
51
+ PORTAL_MESSAGES_PRESIGN_PATH,
52
+ type PortalMessage,
53
+ type PortalMessageAttachment,
54
+ type PortalMessagesPage,
55
+ type PortalThreadState,
56
+ type PortalThreadSummary,
57
+ } from "./usePortalMessages";
@@ -0,0 +1,274 @@
1
+ import { useInfiniteQuery, useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
2
+ import { useForge } from "../../../provider/ForgeProvider";
3
+ import { usePublicAuth } from "../../../contexts/PublicAuthContext";
4
+
5
+ /**
6
+ * Headless data layer for the client portal (`/i/account/*`).
7
+ *
8
+ * Speaks `/public/portal/*`, which answers for the signed-in account on this
9
+ * profile only: every list is "mine", and nothing here takes an account id. The
10
+ * styled portal is built on exactly these, so a creator can build a different
11
+ * portal on the same data.
12
+ *
13
+ * Contract: docs/proposals/unified-client-portal.md, "Backend contract".
14
+ */
15
+
16
+ // ── Types (mirror PortalService responses) ───────────────────────────────────
17
+
18
+ export type PortalSectionKey =
19
+ | "projects"
20
+ | "invoices"
21
+ | "documents"
22
+ | "bookings"
23
+ | "memberships"
24
+ | "courses"
25
+ | "films"
26
+ | "orders"
27
+ | "tickets"
28
+ | "waitlist"
29
+ | "saved"
30
+ /** 1 when the person may message the creator, else 0. */
31
+ | "messages"
32
+ | "files";
33
+
34
+ export type PortalAttentionItem =
35
+ | {
36
+ kind: "invoice_due" | "invoice_overdue";
37
+ id: string;
38
+ invoiceNumber: string;
39
+ total: string;
40
+ currency: string;
41
+ dueDate: string | null;
42
+ projectId: string | null;
43
+ projectName: string | null;
44
+ }
45
+ | { kind: "document_to_sign"; id: string; title: string; token: string; sentAt: string; expiresAt: string | null }
46
+ | {
47
+ kind: "approval_requested";
48
+ id: string;
49
+ milestoneId: string;
50
+ milestoneName: string;
51
+ projectId: string;
52
+ projectName: string;
53
+ requestedAt: string;
54
+ note: string | null;
55
+ }
56
+ | { kind: "membership_payment_failed"; id: string; communityName: string; communitySlug: string }
57
+ | {
58
+ kind: "message_unread";
59
+ /** The conversation's id. */
60
+ id: string;
61
+ unreadCount: number;
62
+ lastMessageAt: string;
63
+ preview: string | null;
64
+ };
65
+
66
+ export interface PortalSummary {
67
+ sections: Record<PortalSectionKey, number>;
68
+ /** The newest thing that happened in each group. Orders the navigation. */
69
+ lastActivity: { work: string | null; library: string | null };
70
+ /** Already ranked by the server: overdue first, then approvals, documents, due. */
71
+ attention: PortalAttentionItem[];
72
+ }
73
+
74
+ export interface PortalInvoice {
75
+ id: string;
76
+ invoiceNumber: string;
77
+ status: string;
78
+ total: string;
79
+ currency: string;
80
+ issueDate: string;
81
+ dueDate: string | null;
82
+ paidAt: string | null;
83
+ projectId: string | null;
84
+ projectName: string | null;
85
+ }
86
+
87
+ export interface PortalDocument {
88
+ documentId: string;
89
+ title: string;
90
+ kind: string;
91
+ documentStatus: string;
92
+ recipientStatus: string;
93
+ needsAction: boolean;
94
+ /** The recipient's own access token: the key to `/i/documents/$token`. */
95
+ token: string;
96
+ sentAt: string;
97
+ completedAt: string | null;
98
+ expiresAt: string | null;
99
+ }
100
+
101
+ export type PortalActivityKind =
102
+ | "invoice_issued"
103
+ | "invoice_paid"
104
+ | "document_sent"
105
+ | "document_signed"
106
+ | "milestone_completed"
107
+ | "comment_added"
108
+ | "approval_requested"
109
+ | "approval_decided"
110
+ | "order_placed"
111
+ | "booking_confirmed"
112
+ | "message_received";
113
+
114
+ export interface PortalActivityItem {
115
+ id: string;
116
+ kind: PortalActivityKind;
117
+ occurredAt: string;
118
+ title: string;
119
+ projectId: string | null;
120
+ projectName: string | null;
121
+ entityId: string;
122
+ detail: string | null;
123
+ }
124
+
125
+ export interface PortalActivityPage {
126
+ items: PortalActivityItem[];
127
+ nextBefore: string | null;
128
+ }
129
+
130
+ export type ApprovalStatus = "pending" | "approved" | "changes_requested" | "cancelled";
131
+
132
+ export interface WorkApprovalRequest {
133
+ id: string;
134
+ milestoneId: string;
135
+ milestoneName: string;
136
+ status: ApprovalStatus;
137
+ note: string | null;
138
+ requestedAt: string;
139
+ decidedAt: string | null;
140
+ decisionComment: string | null;
141
+ decidedByName: string | null;
142
+ }
143
+
144
+ export type ApprovalDecision = "approve" | "request_changes";
145
+
146
+ // ── Keys ─────────────────────────────────────────────────────────────────────
147
+
148
+ const keys = {
149
+ summary: (profileId?: string, accountId?: string) => ["portal-summary", profileId, accountId] as const,
150
+ invoices: (profileId?: string, accountId?: string, status?: string) =>
151
+ ["portal-invoices", profileId, accountId, status] as const,
152
+ documents: (profileId?: string, accountId?: string) => ["portal-documents", profileId, accountId] as const,
153
+ activity: (profileId?: string, accountId?: string, limit?: number) =>
154
+ ["portal-activity", profileId, accountId, limit] as const,
155
+ approvals: (profileId?: string, projectId?: string) => ["work-approvals", profileId, projectId] as const,
156
+ };
157
+
158
+ // ── Hooks ────────────────────────────────────────────────────────────────────
159
+
160
+ /** What needs the person, and which sections they have anything in. */
161
+ export function usePortalSummary(): UseQueryResult<PortalSummary> {
162
+ const { client, profileId } = useForge();
163
+ const { user } = usePublicAuth();
164
+ return useQuery({
165
+ queryKey: keys.summary(profileId, user?.id),
166
+ queryFn: async () => {
167
+ const res = await client.get("/public/portal/summary", { params: { profileId } });
168
+ return res.data as PortalSummary;
169
+ },
170
+ enabled: !!profileId && !!client && !!user,
171
+ // The rail and the bottom bar read this on every portal page; a person
172
+ // moving between sections should not watch the navigation reload.
173
+ staleTime: 30_000,
174
+ });
175
+ }
176
+
177
+ /** Every invoice the creator has sent this person, newest first. */
178
+ export function usePortalInvoices(status: "open" | "paid" | "all" = "all", pageSize = 50) {
179
+ const { client, profileId } = useForge();
180
+ const { user } = usePublicAuth();
181
+ return useQuery({
182
+ queryKey: keys.invoices(profileId, user?.id, `${status}:${pageSize}`),
183
+ queryFn: async () => {
184
+ const res = await client.get("/public/portal/invoices", { params: { profileId, status, page: 1, pageSize } });
185
+ return res.data as { data: PortalInvoice[]; total: number; page: number; pageSize: number };
186
+ },
187
+ enabled: !!profileId && !!client && !!user,
188
+ });
189
+ }
190
+
191
+ /** Documents sent to this person for signature or acceptance. */
192
+ export function usePortalDocuments(): UseQueryResult<PortalDocument[]> {
193
+ const { client, profileId } = useForge();
194
+ const { user } = usePublicAuth();
195
+ return useQuery({
196
+ queryKey: keys.documents(profileId, user?.id),
197
+ queryFn: async () => {
198
+ const res = await client.get("/public/portal/documents", { params: { profileId } });
199
+ return (res.data as { data: PortalDocument[] }).data;
200
+ },
201
+ enabled: !!profileId && !!client && !!user,
202
+ });
203
+ }
204
+
205
+ /** The activity timeline, newest first, one page at a time. */
206
+ export function usePortalActivity(limit = 20) {
207
+ const { client, profileId } = useForge();
208
+ const { user } = usePublicAuth();
209
+ return useInfiniteQuery({
210
+ queryKey: keys.activity(profileId, user?.id, limit),
211
+ initialPageParam: undefined as string | undefined,
212
+ queryFn: async ({ pageParam }) => {
213
+ const res = await client.get("/public/portal/activity", {
214
+ params: { profileId, limit, ...(pageParam ? { before: pageParam } : {}) },
215
+ });
216
+ return res.data as PortalActivityPage;
217
+ },
218
+ getNextPageParam: (last) => last.nextBefore ?? undefined,
219
+ enabled: !!profileId && !!client && !!user,
220
+ });
221
+ }
222
+
223
+ export interface WorkApprovalRequests {
224
+ /** Newest first, every status: the project's approval history. */
225
+ requests: WorkApprovalRequest[];
226
+ /**
227
+ * Whether THIS client may decide. A view-only seat reads the same history but
228
+ * must not be offered Approve, which the decision endpoint would refuse.
229
+ */
230
+ canApprove: boolean;
231
+ }
232
+
233
+ /** Every approval the creator has asked of this client on one project, and whether they may decide. */
234
+ export function useWorkApprovalRequests(projectId: string): UseQueryResult<WorkApprovalRequests> {
235
+ const { client, profileId } = useForge();
236
+ return useQuery({
237
+ queryKey: keys.approvals(profileId, projectId),
238
+ queryFn: async () => {
239
+ const res = await client.get(`/public/work/projects/${projectId}/approval-requests`, { params: { profileId } });
240
+ const body = res.data as { data: WorkApprovalRequest[]; canApprove?: boolean };
241
+ // An API build older than the flag decided nothing here; treat it as
242
+ // "may approve", which is exactly how the page behaved before.
243
+ return { requests: body.data, canApprove: body.canApprove ?? true };
244
+ },
245
+ enabled: !!profileId && !!client && !!projectId,
246
+ });
247
+ }
248
+
249
+ /**
250
+ * Approve a milestone, or send it back with a comment.
251
+ *
252
+ * Approving completes the milestone, so the project report, the summary and the
253
+ * activity timeline all change with it, and all three are refetched.
254
+ */
255
+ export function useDecideApproval(projectId: string) {
256
+ const { client, profileId } = useForge();
257
+ const queryClient = useQueryClient();
258
+ return useMutation({
259
+ mutationFn: async (input: { requestId: string; decision: ApprovalDecision; comment?: string }) => {
260
+ const res = await client.post(
261
+ `/public/work/projects/${projectId}/approval-requests/${input.requestId}/decision`,
262
+ { decision: input.decision, comment: input.comment },
263
+ { params: { profileId } },
264
+ );
265
+ return res.data as WorkApprovalRequest;
266
+ },
267
+ onSuccess: () => {
268
+ void queryClient.invalidateQueries({ queryKey: keys.approvals(profileId, projectId) });
269
+ void queryClient.invalidateQueries({ queryKey: ["portal-summary"] });
270
+ void queryClient.invalidateQueries({ queryKey: ["portal-activity"] });
271
+ void queryClient.invalidateQueries({ queryKey: ["work-portal-report", profileId, projectId] });
272
+ },
273
+ });
274
+ }