@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
@@ -0,0 +1,96 @@
1
+ // Communities: every page of a community has a headless version (decision 14).
2
+ // A styled component is one of these hooks plus markup and nothing else, so a
3
+ // creator can rebuild the list, the About page, the join sheet, the pending
4
+ // screen, the upgrade sheet and the membership menu on their own routes.
5
+ export {
6
+ CommunityProvider,
7
+ useCommunityContext,
8
+ useOptionalCommunityContext,
9
+ isCommunityId,
10
+ MissingCommunityProviderError,
11
+ CommunityUpgradeOpenerProvider,
12
+ useCommunityUpgradeOpener,
13
+ type CommunityContextValue,
14
+ type CommunityProviderProps,
15
+ type CommunityUpgradeOpener,
16
+ } from "../../../contexts/CommunityContext";
17
+ export {
18
+ useCommunities,
19
+ useCommunity,
20
+ communityJoinOffer,
21
+ viewerHasAccess,
22
+ COMMUNITY_ACCESS_STATES,
23
+ type CommunityJoinOffer,
24
+ type UseCommunityOptions,
25
+ } from "./useCommunity";
26
+ export {
27
+ useCommunityInvite,
28
+ describeCommunityInvite,
29
+ type CommunityInviteProblem,
30
+ type CommunityInviteSummary,
31
+ } from "./useCommunityInvite";
32
+ export {
33
+ useCommunityJoin,
34
+ communityJoinStep,
35
+ tierHasChoice,
36
+ type CommunityJoinStep,
37
+ type CommunityJoinStepInput,
38
+ type UseCommunityJoinOptions,
39
+ } from "./useCommunityJoin";
40
+ export {
41
+ useCommunityUpgrade,
42
+ type CommunityUpgradeStep,
43
+ type CommunityUpgradeOption,
44
+ type UseCommunityUpgradeOptions,
45
+ } from "./useCommunityUpgrade";
46
+ export { useCommunityMembership, communityDowngradeTiers } from "./useCommunityMembership";
47
+ export {
48
+ useCommunityAbout,
49
+ type CommunityAboutAction,
50
+ type UseCommunityAboutOptions,
51
+ } from "./useCommunityAbout";
52
+ export { useCommunityHome, type UseCommunityHomeOptions } from "./useCommunityHome";
53
+ export { useMyMemberships, toMyMembershipEntry, type MyMembershipEntry } from "./useMyMemberships";
54
+ export { CommunityGate, communityGateAllows, type CommunityGateProps, type CommunityGateState } from "./CommunityGate";
55
+ export { communitiesListPath, communityAboutPath, communityHomePath } from "./paths";
56
+ export {
57
+ useCommunityWiki,
58
+ useCommunityWikiPage,
59
+ shapeWikiList,
60
+ defaultWikiPageSlug,
61
+ parseWikiContent,
62
+ extractWikiToc,
63
+ wikiHeadingSlug,
64
+ lexicalNodeText,
65
+ wikiImageSource,
66
+ wikiMediaImageStatus,
67
+ wikiUnlockParts,
68
+ useWikiMediaImage,
69
+ type WikiImageSource,
70
+ type WikiMediaImageStatus,
71
+ WIKI_TOC_MIN_HEADINGS,
72
+ type CommunityWikiTocEntry,
73
+ type UseCommunityWikiOptions,
74
+ } from "./useCommunityWiki";
75
+ export {
76
+ useCommunityClassroom,
77
+ classroomProgressPercent,
78
+ classroomCourseState,
79
+ classroomUnlockParts,
80
+ classroomCourseHref,
81
+ type ClassroomCourseState,
82
+ } from "./useCommunityClassroom";
83
+ export {
84
+ useCommunityCalendar,
85
+ calendarDayKey,
86
+ calendarMonthStart,
87
+ calendarMonthWindow,
88
+ calendarItemIsOver,
89
+ groupCalendarItems,
90
+ rsvpErrorCode,
91
+ nextRsvp,
92
+ callJoinable,
93
+ type CommunityCalendarDay,
94
+ type CommunityRsvpErrorCode,
95
+ type UseCommunityCalendarOptions,
96
+ } from "./useCommunityCalendar";
@@ -0,0 +1,36 @@
1
+ import { internalPath } from "../../../runtime/pages";
2
+
3
+ /**
4
+ * The hosted community pages, as plain paths. Built through `internalPath` so a
5
+ * page rename is a type error here rather than a dead link on a published site.
6
+ *
7
+ * Every flow that ends (a join, a paid join's Stripe return, an upgrade, an
8
+ * application) lands on the community HOME, never on `/i/account`.
9
+ */
10
+
11
+ type Query = Record<string, string | number | null | undefined>;
12
+
13
+ function withQuery(path: string, query?: Query): string {
14
+ if (!query) return path;
15
+ const params = new URLSearchParams();
16
+ for (const [key, value] of Object.entries(query)) {
17
+ if (value !== undefined && value !== null && value !== "") params.set(key, String(value));
18
+ }
19
+ const qs = params.toString();
20
+ return qs ? `${path}?${qs}` : path;
21
+ }
22
+
23
+ /** Every community on the profile: `/i/communities`. */
24
+ export function communitiesListPath(): string {
25
+ return internalPath("communities");
26
+ }
27
+
28
+ /** A community's About page, e.g. `communityAboutPath("fan-club", { tier })`. */
29
+ export function communityAboutPath(slug: string, query?: { invite?: string; join?: string; tier?: string }): string {
30
+ return withQuery(internalPath("communities/$slug", { slug }), query);
31
+ }
32
+
33
+ /** The inside of a community. `welcome=1` after a join, `confirmSubscription=1` after a Stripe return. */
34
+ export function communityHomePath(slug: string, query?: { welcome?: 1 | "1"; confirmSubscription?: 1 | "1" }): string {
35
+ return withQuery(internalPath("communities/$slug/home", { slug }), query);
36
+ }
@@ -0,0 +1,140 @@
1
+ import { useContext, useMemo } from "react";
2
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
3
+ import { useOptionalCommunityContext, MissingCommunityProviderError } from "../../../contexts/CommunityContext";
4
+ import {
5
+ isNotFound,
6
+ usePublicCommunities,
7
+ usePublicCommunity,
8
+ useCommunityViewerState,
9
+ type CommunityTokens,
10
+ type CommunityViewerState,
11
+ type PublicCommunity,
12
+ type PublicTier,
13
+ } from "../../../data/queries/useCommunities";
14
+ import { cycleFloor, cycleIsFree, defaultCycle, offeredCycles, type BillingCycle } from "../../format/membershipPwyw";
15
+
16
+ /**
17
+ * Every community on the profile, for a list page.
18
+ *
19
+ * `only` is the one community when there is exactly one, so a list page can go
20
+ * straight to it rather than asking the fan to choose between one option.
21
+ */
22
+ export function useCommunities() {
23
+ const query = usePublicCommunities();
24
+ const communities = query.data ?? [];
25
+ return {
26
+ communities,
27
+ only: communities.length === 1 ? communities[0]! : null,
28
+ isLoading: query.isLoading,
29
+ isError: query.isError,
30
+ refetch: query.refetch,
31
+ };
32
+ }
33
+
34
+ /** What the About page's single Join button offers, decided from the creator's tiers. */
35
+ export type CommunityJoinOffer =
36
+ | { kind: "free"; tier: PublicTier }
37
+ | { kind: "paid"; tier: PublicTier; amount: number; cycle: BillingCycle }
38
+ | { kind: "apply"; tier: PublicTier }
39
+ | null;
40
+
41
+ /**
42
+ * The Join button reflects the creator's tiers (decision 8): "Join free" when a
43
+ * free tier exists, otherwise "Join" with the lowest price, and "Apply" when
44
+ * every tier is apply-to-join. Pure, so a custom About page words it the same.
45
+ */
46
+ export function communityJoinOffer(tiers: PublicTier[]): CommunityJoinOffer {
47
+ if (!tiers.length) return null;
48
+ const joinable = tiers.filter((tier) => tier.joinMode !== "application");
49
+ const free = joinable.find((tier) => cycleIsFree(tier, defaultCycle(tier)));
50
+ if (free) return { kind: "free", tier: free };
51
+ let cheapest: { tier: PublicTier; amount: number; cycle: BillingCycle } | null = null;
52
+ for (const tier of joinable) {
53
+ const cycles = offeredCycles(tier);
54
+ // A pay-what-you-want tier with no floor sells no cycle at a price, and is
55
+ // still a paid join from nothing.
56
+ const candidates = (["month", "year"] as const).filter((cycle) => cycles[cycle]);
57
+ for (const cycle of candidates.length ? candidates : (["month"] as const)) {
58
+ const amount = cycleFloor(tier, cycle);
59
+ // Compare on a monthly footing so a yearly price is not mistaken for the dearer one.
60
+ const monthly = cycle === "year" ? amount / 12 : amount;
61
+ const best = cheapest ? (cheapest.cycle === "year" ? cheapest.amount / 12 : cheapest.amount) : Infinity;
62
+ if (monthly < best) cheapest = { tier, amount, cycle };
63
+ }
64
+ }
65
+ if (cheapest) return { kind: "paid", ...cheapest };
66
+ return { kind: "apply", tier: tiers[0]! };
67
+ }
68
+
69
+ /** The viewer states that hold access to member content. `past_due` keeps it through the grace window. */
70
+ export const COMMUNITY_ACCESS_STATES: ReadonlyArray<CommunityViewerState["state"]> = ["member", "past_due"];
71
+
72
+ /** Does this viewer state grant access to the community's member content. */
73
+ export function viewerHasAccess(viewer: Pick<CommunityViewerState, "state"> | null | undefined): boolean {
74
+ return !!viewer && COMMUNITY_ACCESS_STATES.includes(viewer.state);
75
+ }
76
+
77
+ const NO_TIERS: PublicTier[] = [];
78
+
79
+ export interface UseCommunityOptions extends CommunityTokens {
80
+ enabled?: boolean;
81
+ }
82
+
83
+ /**
84
+ * One community: what it is, the tiers this viewer may join, and where the
85
+ * viewer stands IN IT (`none`, `pending`, `member`, `past_due`, `cancelled`, or
86
+ * `anonymous`), plus the upgrade and pending details a custom page needs.
87
+ *
88
+ * `slug` is a slug or an id. Omitted, the surrounding `<CommunityProvider>` is
89
+ * read, and outside one this throws rather than guess a community.
90
+ *
91
+ * The viewer comes from `/me` once signed in, so it follows a sign-in that
92
+ * happened on this page (the join sheet) without a reload.
93
+ */
94
+ export function useCommunity(slug?: string, opts: UseCommunityOptions = {}) {
95
+ const context = useOptionalCommunityContext();
96
+ const ref = slug ?? context?.ref;
97
+ if (!ref) throw new MissingCommunityProviderError("useCommunity()");
98
+ const tokens: CommunityTokens = { invite: opts.invite ?? null, join: opts.join ?? null };
99
+ const signedIn = !!useContext(PublicAuthContext)?.user;
100
+
101
+ const detail = usePublicCommunity(ref, tokens, { enabled: opts.enabled });
102
+ const me = useCommunityViewerState(ref, { enabled: signedIn && opts.enabled !== false });
103
+
104
+ const community: PublicCommunity | undefined = detail.data?.community ?? context?.community;
105
+ const tiers: PublicTier[] = detail.data?.tiers ?? NO_TIERS;
106
+ const viewer: CommunityViewerState | undefined = (signedIn ? me.data : undefined) ?? detail.data?.viewer;
107
+
108
+ return useMemo(() => {
109
+ const membership = viewer?.membership ?? null;
110
+ const currentTier = membership ? (tiers.find((tier) => tier.id === membership.tierId) ?? null) : null;
111
+ return {
112
+ community,
113
+ communityId: community?.id,
114
+ slug: community?.slug,
115
+ tiers,
116
+ viewer,
117
+ /** The invite the URL carried, as the community read resolved it. */
118
+ invite: detail.data?.invite ?? null,
119
+ isLoading: detail.isLoading || (signedIn && me.isLoading),
120
+ /** True while the viewer is being re-read, e.g. straight after signing in. */
121
+ viewerLoading: signedIn && (me.isLoading || me.isFetching),
122
+ isError: detail.isError,
123
+ notFound: isNotFound(detail.error),
124
+ /** An active member (including `past_due`, which keeps access through the grace window). */
125
+ isMember: viewerHasAccess(viewer),
126
+ /** Applied and waiting for the creator's decision: inside, but no member content. */
127
+ isPending: viewer?.state === "pending",
128
+ /** When the pending application was made, for a waiting screen. */
129
+ pendingSince: viewer?.state === "pending" ? (viewer.application?.createdAt ?? null) : null,
130
+ membership,
131
+ currentTier,
132
+ upgradeTiers: viewer?.upgradeTiers ?? [],
133
+ hasUpgrade: (viewer?.upgradeTiers?.length ?? 0) > 0,
134
+ canInteract: viewer?.canInteract ?? false,
135
+ joinOffer: communityJoinOffer(tiers),
136
+ refetch: () => Promise.all([detail.refetch(), signedIn ? me.refetch() : Promise.resolve()]),
137
+ };
138
+ // eslint-disable-next-line react-hooks/exhaustive-deps
139
+ }, [community, tiers, viewer, detail.data?.invite, detail.isLoading, detail.isError, detail.error, me.isLoading, me.isFetching, signedIn]);
140
+ }
@@ -0,0 +1,71 @@
1
+ import { useEffect, useState } from "react";
2
+ import { MissingCommunityProviderError, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
3
+ import { useCommunity } from "./useCommunity";
4
+ import { communityHomePath } from "./paths";
5
+
6
+ /** What the About page's main button does for THIS viewer. */
7
+ export type CommunityAboutAction = "join" | "enter" | "pending";
8
+
9
+ export interface UseCommunityAboutOptions {
10
+ /** A per-email invite token (`?invite=`). Opens the join sheet straight away. */
11
+ invite?: string | null;
12
+ /** A shareable invite link token (`?join=`). Opens the join sheet straight away. */
13
+ join?: string | null;
14
+ /** A tier to preselect (`?tier=`). */
15
+ tierId?: string | null;
16
+ /** The host's router, for "go inside". Default a full-page visit. */
17
+ navigate?: (href: string) => void;
18
+ }
19
+
20
+ /**
21
+ * The About page: the community, what each tier gives, and ONE main action.
22
+ *
23
+ * - `join` opens the join sheet in place (it never leaves the page).
24
+ * - `enter` for a member, who skips Join and goes straight inside.
25
+ * - `pending` for a fan whose application is being reviewed; they go inside to
26
+ * the pending screen.
27
+ *
28
+ * The sheet opens by itself when the URL carries an invite or a shareable
29
+ * link, already set to the invited tier. A signed-in member arriving on an
30
+ * invite link is still shown the sheet: accepting it may be a tier change.
31
+ */
32
+ export function useCommunityAbout(opts: UseCommunityAboutOptions = {}) {
33
+ if (!useOptionalCommunityContext()) throw new MissingCommunityProviderError("useCommunityAbout()");
34
+ const community = useCommunity();
35
+ const hasToken = !!(opts.invite || opts.join);
36
+ const [sheetOpen, setSheetOpen] = useState(hasToken);
37
+ const [sheetTierId, setSheetTierId] = useState<string | null>(opts.tierId ?? null);
38
+
39
+ // A token that arrives after the first render (client-side navigation) still opens the sheet.
40
+ useEffect(() => {
41
+ if (hasToken) setSheetOpen(true);
42
+ }, [hasToken]);
43
+
44
+ const action: CommunityAboutAction = community.isMember ? "enter" : community.isPending ? "pending" : "join";
45
+ const homePath = community.slug ? communityHomePath(community.slug) : "";
46
+
47
+ const goInside = () => {
48
+ if (!homePath) return;
49
+ if (opts.navigate) opts.navigate(homePath);
50
+ else if (typeof window !== "undefined") window.location.assign(homePath);
51
+ };
52
+
53
+ return {
54
+ ...community,
55
+ action,
56
+ homePath,
57
+ /** Run the main action: open the sheet, or go inside. */
58
+ primary: () => (action === "join" ? (setSheetTierId(opts.tierId ?? null), setSheetOpen(true)) : goInside()),
59
+ goInside,
60
+ sheetOpen,
61
+ /** The tier the sheet opens on: a tier card's own Join, or `?tier=`. */
62
+ sheetTierId,
63
+ openJoin: (tierId?: string | null) => {
64
+ setSheetTierId(tierId ?? opts.tierId ?? null);
65
+ setSheetOpen(true);
66
+ },
67
+ setSheetOpen,
68
+ invite: opts.invite ?? null,
69
+ join: opts.join ?? null,
70
+ };
71
+ }
@@ -0,0 +1,156 @@
1
+ import { useMemo, useState } from "react";
2
+ import { communityRequestStatus } from "../../../data/queries/useCommunityClassroom";
3
+ import {
4
+ useCommunityCalendarItems,
5
+ useCommunityCallRsvp,
6
+ type CommunityCalendarCall,
7
+ type CommunityCalendarItem,
8
+ type CommunityRsvpResponse,
9
+ } from "../../../data/queries/useCommunityCalendar";
10
+
11
+ // ── Pure helpers (unit-tested; no React) ─────────────────────────────────────
12
+
13
+ /** One day of the agenda, in the viewer's own time zone. */
14
+ export interface CommunityCalendarDay {
15
+ /** `YYYY-MM-DD`, local. */
16
+ key: string;
17
+ /** Local midnight of the day. */
18
+ date: Date;
19
+ items: CommunityCalendarItem[];
20
+ }
21
+
22
+ const pad = (n: number) => String(n).padStart(2, "0");
23
+
24
+ /** The local day an instant falls on, as `YYYY-MM-DD`. */
25
+ export function calendarDayKey(value: Date | string): string {
26
+ const date = typeof value === "string" ? new Date(value) : value;
27
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
28
+ }
29
+
30
+ /** Local midnight on the first of the month `offset` months from `now`. */
31
+ export function calendarMonthStart(now: Date, offset = 0): Date {
32
+ return new Date(now.getFullYear(), now.getMonth() + offset, 1);
33
+ }
34
+
35
+ /** The window one month reads: its first local midnight up to the next month's. */
36
+ export function calendarMonthWindow(monthStart: Date): { from: Date; to: Date } {
37
+ return {
38
+ from: new Date(monthStart.getFullYear(), monthStart.getMonth(), 1),
39
+ to: new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1),
40
+ };
41
+ }
42
+
43
+ /** When an item is over: its end, or its start when it has no end. */
44
+ export function calendarItemIsOver(item: Pick<CommunityCalendarItem, "startsAt" | "endsAt">, now: Date): boolean {
45
+ return new Date(item.endsAt ?? item.startsAt).getTime() < now.getTime();
46
+ }
47
+
48
+ /**
49
+ * The agenda, grouped by the local day each item starts on and split in two:
50
+ * `upcoming` (anything not yet over, soonest first) and `past` (most recent
51
+ * day first), so what a member can still act on is always at the top. Items
52
+ * keep their time order inside a day.
53
+ */
54
+ export function groupCalendarItems(
55
+ items: CommunityCalendarItem[] | undefined,
56
+ now: Date,
57
+ ): { upcoming: CommunityCalendarDay[]; past: CommunityCalendarDay[] } {
58
+ const sorted = [...(Array.isArray(items) ? items : [])].sort((a, b) => a.startsAt.localeCompare(b.startsAt));
59
+ const group = (list: CommunityCalendarItem[]) => {
60
+ const days = new Map<string, CommunityCalendarDay>();
61
+ for (const item of list) {
62
+ const key = calendarDayKey(item.startsAt);
63
+ let day = days.get(key);
64
+ if (!day) {
65
+ const start = new Date(item.startsAt);
66
+ day = { key, date: new Date(start.getFullYear(), start.getMonth(), start.getDate()), items: [] };
67
+ days.set(key, day);
68
+ }
69
+ day.items.push(item);
70
+ }
71
+ return [...days.values()];
72
+ };
73
+ const upcoming = group(sorted.filter((item) => !calendarItemIsOver(item, now)));
74
+ const past = group(sorted.filter((item) => calendarItemIsOver(item, now))).reverse();
75
+ return { upcoming, past };
76
+ }
77
+
78
+ /** What a failed going or not going means for the member. */
79
+ export type CommunityRsvpErrorCode = "closed" | "not_found" | "failed";
80
+
81
+ export function rsvpErrorCode(error: unknown): CommunityRsvpErrorCode {
82
+ const status = communityRequestStatus(error);
83
+ if (status === 409) return "closed";
84
+ if (status === 404) return "not_found";
85
+ return "failed";
86
+ }
87
+
88
+ /** Pressing the answer a member already gave clears it; any other answer replaces it. */
89
+ export function nextRsvp(
90
+ current: CommunityRsvpResponse | null,
91
+ pressed: CommunityRsvpResponse,
92
+ ): CommunityRsvpResponse | null {
93
+ return current === pressed ? null : pressed;
94
+ }
95
+
96
+ /** Join is offered only while the server says the room is open and there is a room to join. */
97
+ export function callJoinable(call: Pick<CommunityCalendarCall, "isOpen" | "callPublicId">): boolean {
98
+ return call.isOpen && !!call.callPublicId;
99
+ }
100
+
101
+ // ── Hook ─────────────────────────────────────────────────────────────────────
102
+
103
+ export interface UseCommunityCalendarOptions {
104
+ /** The clock, for tests. Defaults to the time of the first render. */
105
+ now?: Date;
106
+ }
107
+
108
+ /**
109
+ * Headless community calendar: one month at a time (this month first, arrows
110
+ * either way), grouped by day with what is still ahead on top, and going or
111
+ * not going on a call. Answering the answer already given clears it. A refused
112
+ * answer (the call is over or was cancelled, 409) is kept per call for the
113
+ * row to show.
114
+ */
115
+ export function useCommunityCalendar(opts: UseCommunityCalendarOptions = {}) {
116
+ const [initialNow] = useState(() => opts.now ?? new Date());
117
+ const now = opts.now ?? initialNow;
118
+ const [offset, setOffset] = useState(0);
119
+ const monthStart = useMemo(() => calendarMonthStart(now, offset), [now, offset]);
120
+ const range = useMemo(() => calendarMonthWindow(monthStart), [monthStart]);
121
+ const query = useCommunityCalendarItems(range);
122
+ const groups = useMemo(() => groupCalendarItems(query.data, now), [query.data, now]);
123
+ const rsvpMutation = useCommunityCallRsvp();
124
+ const [rsvpError, setRsvpError] = useState<{ callId: string; code: CommunityRsvpErrorCode } | null>(null);
125
+ const status = communityRequestStatus(query.error);
126
+
127
+ const answer = (call: CommunityCalendarCall, pressed: CommunityRsvpResponse) => {
128
+ setRsvpError(null);
129
+ rsvpMutation.mutate(
130
+ { callId: call.id, response: nextRsvp(call.rsvp.mine, pressed) },
131
+ { onError: (error) => setRsvpError({ callId: call.id, code: rsvpErrorCode(error) }) },
132
+ );
133
+ };
134
+
135
+ return {
136
+ monthStart,
137
+ isCurrentMonth: offset === 0,
138
+ goToPreviousMonth: () => setOffset((o) => o - 1),
139
+ goToNextMonth: () => setOffset((o) => o + 1),
140
+ goToToday: () => setOffset(0),
141
+ items: Array.isArray(query.data) ? query.data : [],
142
+ upcoming: groups.upcoming,
143
+ past: groups.past,
144
+ now,
145
+ isLoading: query.isLoading,
146
+ /** Signed out, or not a member of this community. */
147
+ forbidden: status === 401 || status === 403,
148
+ isError: query.isError && status !== 401 && status !== 403,
149
+ /** Answer going or not going; pressing the current answer clears it. */
150
+ answer,
151
+ /** The call whose answer is being saved. */
152
+ pendingCallId: rsvpMutation.isPending ? (rsvpMutation.variables?.callId ?? null) : null,
153
+ /** Why a call's last answer was refused. */
154
+ rsvpErrorFor: (callId: string) => (rsvpError?.callId === callId ? rsvpError.code : null),
155
+ };
156
+ }
@@ -0,0 +1,84 @@
1
+ import { useCommunityUpgradeOpener, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
2
+ import {
3
+ communityRequestStatus,
4
+ useCommunityClassroomCourses,
5
+ type CommunityClassroomCourse,
6
+ type CommunityClassroomProgress,
7
+ } from "../../../data/queries/useCommunityClassroom";
8
+ import { internalPath } from "../../../runtime/pages";
9
+ import { communityAboutPath } from "./paths";
10
+ import { wikiUnlockParts } from "./useCommunityWiki";
11
+
12
+ // ── Pure helpers (unit-tested; no React) ─────────────────────────────────────
13
+
14
+ /** Where a member stands with one course card. */
15
+ export type ClassroomCourseState = "locked" | "not_started" | "in_progress" | "completed";
16
+
17
+ /** How far through a course the member is, 0 to 100. */
18
+ export function classroomProgressPercent(progress: CommunityClassroomProgress | null | undefined): number {
19
+ if (!progress) return 0;
20
+ if (progress.completedAt) return 100;
21
+ const total = Number(progress.total) || 0;
22
+ if (total <= 0) return 0;
23
+ return Math.max(0, Math.min(100, Math.round(((Number(progress.completed) || 0) / total) * 100)));
24
+ }
25
+
26
+ export function classroomCourseState(course: Pick<CommunityClassroomCourse, "open" | "progress">): ClassroomCourseState {
27
+ if (!course.open) return "locked";
28
+ const progress = course.progress;
29
+ if (!progress) return "not_started";
30
+ if (progress.completedAt || (progress.total > 0 && progress.completed >= progress.total)) return "completed";
31
+ return progress.completed > 0 ? "in_progress" : "not_started";
32
+ }
33
+
34
+ /**
35
+ * What opens a locked course: the level the member has not reached (null when
36
+ * the level is not what is missing) and the names of the tiers that include
37
+ * it (empty when none are named). The same reading as a locked wiki page.
38
+ */
39
+ export function classroomUnlockParts(course: Pick<CommunityClassroomCourse, "unlockLevel" | "unlockTiers">) {
40
+ return wikiUnlockParts(course);
41
+ }
42
+
43
+ /**
44
+ * Where an open card leads: the course player, addressed by the member's
45
+ * access id. Null without one. The course's own page is the sales page, a dead
46
+ * end for someone who already holds the course, so it is never the fallback.
47
+ */
48
+ export function classroomCourseHref(course: Pick<CommunityClassroomCourse, "open" | "accessId">): string | null {
49
+ return course.open && course.accessId ? internalPath("course-access/$accessId", { accessId: course.accessId }) : null;
50
+ }
51
+
52
+ // ── Hook ─────────────────────────────────────────────────────────────────────
53
+
54
+ /**
55
+ * Headless classroom for the surrounding community: the course cards, open
56
+ * ones first as the server orders them, whether the viewer may see the tab at
57
+ * all (`forbidden` for a non-member), and how to unlock a locked card. `unlock`
58
+ * opens the community's upgrade sheet on the first tier that includes the
59
+ * course when a community home provides one; otherwise `unlockHref` is the
60
+ * About page with that tier chosen. A card only a level holds back has nothing
61
+ * to buy, so it offers no upgrade.
62
+ */
63
+ export function useCommunityClassroom(opts: { enabled?: boolean } = {}) {
64
+ const community = useOptionalCommunityContext();
65
+ const opener = useCommunityUpgradeOpener();
66
+ const query = useCommunityClassroomCourses(opts);
67
+ const status = communityRequestStatus(query.error);
68
+ const courses = Array.isArray(query.data) ? query.data : [];
69
+
70
+ return {
71
+ courses,
72
+ isLoading: query.isLoading,
73
+ /** Signed out, or not a member of this community. */
74
+ forbidden: status === 401 || status === 403,
75
+ notFound: status === 404,
76
+ isError: query.isError && status !== 401 && status !== 403 && status !== 404,
77
+ refetch: query.refetch,
78
+ /** A locked card offers an upgrade only when a tier includes it. */
79
+ canUpgrade: (course: CommunityClassroomCourse) => !course.open && course.unlockTiers.length > 0,
80
+ unlock: opener ? (course: CommunityClassroomCourse) => opener.openUpgrade(course.unlockTiers[0]?.id ?? null) : null,
81
+ unlockHref: (course: CommunityClassroomCourse) =>
82
+ community?.slug ? communityAboutPath(community.slug, { tier: course.unlockTiers[0]?.id }) : null,
83
+ };
84
+ }
@@ -0,0 +1,91 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { MissingCommunityProviderError, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
3
+ import { useConfirmLatestSubscription } from "../../../data/queries/useSubscriptions";
4
+ import { requestAppInstall } from "../siteApp/appPrompts";
5
+ import { useInvalidateCommunities } from "../../../data/queries/useCommunities";
6
+ import { useCommunity } from "./useCommunity";
7
+ import { communityAboutPath } from "./paths";
8
+
9
+ export interface UseCommunityHomeOptions {
10
+ /** `?welcome=1`: the fan just joined. */
11
+ welcome?: boolean;
12
+ /** `?confirmSubscription=1`: back from Stripe. The subscription is reconciled here, inside the community. */
13
+ confirmSubscription?: boolean;
14
+ }
15
+
16
+ /**
17
+ * The state of a community's home shell: the header (name, tier badge,
18
+ * Upgrade), the one upgrade sheet every locked item opens, the Membership
19
+ * menu, a welcome after joining, and reconciling a paid join or upgrade on the
20
+ * way back from Stripe, which used to happen on the account page.
21
+ */
22
+ export function useCommunityHome(opts: UseCommunityHomeOptions = {}) {
23
+ const context = useOptionalCommunityContext();
24
+ if (!context) throw new MissingCommunityProviderError("useCommunityHome()");
25
+ const community = useCommunity();
26
+ const confirmLatest = useConfirmLatestSubscription();
27
+ const invalidate = useInvalidateCommunities();
28
+
29
+ const [welcomeVisible, setWelcomeVisible] = useState(!!opts.welcome);
30
+ const [upgradeOpen, setUpgradeOpen] = useState(false);
31
+ const [upgradeTierId, setUpgradeTierId] = useState<string | null>(null);
32
+ const [membershipOpen, setMembershipOpen] = useState(false);
33
+
34
+ // Reconcile once per arrival, as soon as the community id is known.
35
+ //
36
+ // `confirming` is this component's own state, NOT `confirmLatest.isPending`.
37
+ // A mutation started from an effect loses its observer when React StrictMode
38
+ // unmounts and remounts the component in development, so `isPending` stayed
39
+ // true for ever and the home was stuck on "Confirming" although the request
40
+ // had answered. The promise itself always settles, so state set from it does.
41
+ const confirmed = useRef(false);
42
+ const [confirming, setConfirming] = useState(false);
43
+ const communityId = community.communityId ?? context.communityId;
44
+ useEffect(() => {
45
+ if (!opts.confirmSubscription || confirmed.current || !communityId) return;
46
+ confirmed.current = true;
47
+ setConfirming(true);
48
+ confirmLatest
49
+ .mutateAsync({ communityId })
50
+ // Back from paying to join: the same moment as a free join to offer the app.
51
+ .then(() => requestAppInstall("joined_community"))
52
+ .catch(() => undefined)
53
+ .finally(() => {
54
+ setConfirming(false);
55
+ void invalidate();
56
+ });
57
+ // eslint-disable-next-line react-hooks/exhaustive-deps
58
+ }, [opts.confirmSubscription, communityId]);
59
+
60
+ const opener = useMemo(
61
+ () => ({
62
+ openUpgrade: (tierId?: string | null) => {
63
+ setUpgradeTierId(tierId ?? null);
64
+ setUpgradeOpen(true);
65
+ },
66
+ }),
67
+ [],
68
+ );
69
+
70
+ return {
71
+ ...community,
72
+ /** Show the one-time welcome after a join. */
73
+ welcomeVisible: welcomeVisible && community.isMember,
74
+ dismissWelcome: () => setWelcomeVisible(false),
75
+ /** True while a Stripe return is being reconciled. */
76
+ confirming,
77
+ /** The header's Upgrade button shows only when a higher tier exists. */
78
+ showUpgrade: community.isMember && community.hasUpgrade,
79
+ /** The header's Join link, for a visitor who is not a member. */
80
+ showJoin: !community.isLoading && !community.isMember && !community.isPending,
81
+ joinPath: community.slug ? communityAboutPath(community.slug) : "",
82
+ upgradeOpen,
83
+ upgradeTierId,
84
+ /** Hand to `CommunityUpgradeOpenerProvider` so every locked item opens the same sheet. */
85
+ opener,
86
+ openUpgrade: opener.openUpgrade,
87
+ setUpgradeOpen,
88
+ membershipOpen,
89
+ setMembershipOpen,
90
+ };
91
+ }