@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,182 @@
1
+ import { useMemo, useState } from "react";
2
+ import { MissingCommunityProviderError, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
3
+ import {
4
+ useCancelCommunityMembership,
5
+ useDowngradeCommunityMembership,
6
+ useMyCommunityMemberships,
7
+ useUndoCommunityDowngrade,
8
+ type PublicTier,
9
+ } from "../../../data/queries/useCommunities";
10
+ import { useOpenBillingPortal } from "../../../data/queries/useSubscriptions";
11
+ import { cycleFloor, cycleIsFree, defaultCycle, type BillingCycle } from "../../format/membershipPwyw";
12
+ import { useCommunity } from "./useCommunity";
13
+
14
+ const errMessage = (e: unknown) =>
15
+ (e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Something went wrong.";
16
+
17
+ /** A monthly-equivalent price, so tiers sold on different cycles still order correctly. */
18
+ const monthlyPrice = (tier: PublicTier) => {
19
+ const cycle = defaultCycle(tier);
20
+ const floor = cycleFloor(tier, cycle);
21
+ return cycle === "year" ? floor / 12 : floor;
22
+ };
23
+
24
+ /**
25
+ * The tiers a member can move DOWN to: this community's tiers priced below the
26
+ * current one, excluding anything the upgrade list offers. Pure.
27
+ */
28
+ export function communityDowngradeTiers(tiers: PublicTier[], currentTierId: string | null | undefined, upgradeTierIds: string[]): PublicTier[] {
29
+ const current = tiers.find((tier) => tier.id === currentTierId);
30
+ if (!current) return [];
31
+ const currentPrice = monthlyPrice(current);
32
+ return tiers.filter(
33
+ (tier) =>
34
+ tier.id !== current.id &&
35
+ !upgradeTierIds.includes(tier.id) &&
36
+ tier.joinMode !== "application" &&
37
+ monthlyPrice(tier) < currentPrice,
38
+ );
39
+ }
40
+
41
+ /** What the member pays, as far as the client can tell. */
42
+ export interface CommunityMembershipBilling {
43
+ /** True when the membership is paid for (a subscription with a price). */
44
+ isPaid: boolean;
45
+ /** Per period, in `currency`. Null on a free membership, or while unknown. */
46
+ amount: number | null;
47
+ currency: string | null;
48
+ cycle: BillingCycle | null;
49
+ }
50
+
51
+ /**
52
+ * Is this membership paid for, and what does it cost? Pure.
53
+ *
54
+ * The answer comes from the member's own row in `/public/memberships/mine`
55
+ * (`row`), which carries the subscription's real amount and cycle: a
56
+ * pay-what-you-want member pays what they chose, and a member on a tier sold
57
+ * both ways pays the cycle they picked. Until that row arrives, the tier's own
58
+ * price stands in, so a free tier never flashes "Update payment method".
59
+ */
60
+ export function communityMembershipBilling(
61
+ row: { amount: number | null; currency: string | null; billingCycle: string | null } | null | undefined,
62
+ tier: PublicTier | null | undefined,
63
+ ): CommunityMembershipBilling {
64
+ if (row) {
65
+ const amount = typeof row.amount === "number" && row.amount > 0 ? row.amount : null;
66
+ const cycle = row.billingCycle === "year" ? "year" : row.billingCycle === "month" ? "month" : null;
67
+ return { isPaid: amount !== null, amount, currency: amount !== null ? row.currency : null, cycle: amount !== null ? cycle : null };
68
+ }
69
+ if (!tier) return { isPaid: false, amount: null, currency: null, cycle: null };
70
+ const cycle = defaultCycle(tier);
71
+ return { isPaid: !cycleIsFree(tier, cycle), amount: null, currency: null, cycle: null };
72
+ }
73
+
74
+ /**
75
+ * The Membership menu inside a community: current tier, renewal date, a pending
76
+ * downgrade, and the actions. Cancel keeps the paid period; a downgrade takes
77
+ * effect at period end and can be withdrawn until then (decision 11).
78
+ *
79
+ * Cancel is two-step (`requestCancel()` then `confirmCancel()`), because a
80
+ * destructive action has to be confirmed and the confirmation's wording depends
81
+ * on state the hook owns.
82
+ */
83
+ export function useCommunityMembership() {
84
+ const context = useOptionalCommunityContext();
85
+ if (!context) throw new MissingCommunityProviderError("useCommunityMembership()");
86
+ const community = useCommunity();
87
+ const ref = community.slug ?? context.slug ?? context.ref;
88
+ const cancel = useCancelCommunityMembership(ref);
89
+ const downgrade = useDowngradeCommunityMembership(ref);
90
+ const undo = useUndoCommunityDowngrade(ref);
91
+ const billingPortal = useOpenBillingPortal();
92
+ const mine = useMyCommunityMemberships({ enabled: !!community.membership });
93
+
94
+ const [cancelRequested, setCancelRequested] = useState(false);
95
+ const [error, setError] = useState<string | null>(null);
96
+
97
+ const membership = community.membership;
98
+ const upgradeIds = community.upgradeTiers.map((tier) => tier.id);
99
+ const downgradeTiers = useMemo(
100
+ () => communityDowngradeTiers(community.tiers, membership?.tierId, upgradeIds),
101
+ // eslint-disable-next-line react-hooks/exhaustive-deps
102
+ [community.tiers, membership?.tierId, upgradeIds.join(",")],
103
+ );
104
+ const pendingDowngradeTier = membership?.pendingDowngradeTierId
105
+ ? (community.tiers.find((tier) => tier.id === membership.pendingDowngradeTierId) ?? {
106
+ id: membership.pendingDowngradeTierId,
107
+ name: "",
108
+ })
109
+ : null;
110
+
111
+ const mineRow = membership ? mine.data?.find((row) => row.id === membership.id) : undefined;
112
+ const billing = communityMembershipBilling(
113
+ mineRow ? { amount: mineRow.subscriptionAmount ?? null, currency: mineRow.subscriptionCurrency ?? null, billingCycle: mineRow.billingCycle ?? null } : null,
114
+ community.currentTier,
115
+ );
116
+
117
+ const run = async (action: () => Promise<unknown>) => {
118
+ setError(null);
119
+ try {
120
+ await action();
121
+ await Promise.all([community.refetch(), membership ? mine.refetch() : Promise.resolve()]);
122
+ return true;
123
+ } catch (e) {
124
+ setError(errMessage(e));
125
+ return false;
126
+ }
127
+ };
128
+
129
+ return {
130
+ isLoading: community.isLoading,
131
+ /** False for a viewer with nothing to manage here. */
132
+ hasMembership: !!membership,
133
+ membership,
134
+ currentTier: community.currentTier,
135
+ /** When the paid period ends: the renewal date, or the last day when cancelling. */
136
+ periodEnd: membership?.currentPeriodEnd ?? null,
137
+ isCancelling: !!membership?.cancelAtPeriodEnd,
138
+ pendingDowngradeTier,
139
+ downgradeTiers,
140
+ isPastDue: community.viewer?.state === "past_due",
141
+ /** Paid or free, and the price per period when the server has said. A free membership has no card to update. */
142
+ billing,
143
+ isPaid: billing.isPaid,
144
+ /** A higher tier exists, so the sheet offers Upgrade. */
145
+ hasUpgrade: community.hasUpgrade,
146
+ error,
147
+ isWorking: cancel.isPending || downgrade.isPending || undo.isPending || billingPortal.isPending,
148
+
149
+ cancelRequested,
150
+ /** Ask to cancel: show the confirmation, which must say the paid period is kept. */
151
+ requestCancel: () => {
152
+ setError(null);
153
+ setCancelRequested(true);
154
+ },
155
+ abortCancel: () => setCancelRequested(false),
156
+ /** Cancel for real. Access runs to the end of the paid period. */
157
+ confirmCancel: async () => {
158
+ const ok = await run(() => cancel.mutateAsync());
159
+ if (ok) setCancelRequested(false);
160
+ return ok;
161
+ },
162
+ /** The same, without the confirmation step, for a host that confirms on its own. */
163
+ cancel: () => run(() => cancel.mutateAsync()),
164
+ /** Move down to `tierId` at the end of the paid period. */
165
+ downgrade: (tierId: string) => run(() => downgrade.mutateAsync({ membershipTierId: tierId })),
166
+ /** Withdraw a pending downgrade. */
167
+ undoDowngrade: () => run(() => undo.mutateAsync()),
168
+ /**
169
+ * Open the provider's billing page to change the card. The server derives
170
+ * the customer from the session; the fan comes back to this page.
171
+ */
172
+ updateCard: async () => {
173
+ setError(null);
174
+ try {
175
+ const { url } = await billingPortal.mutateAsync();
176
+ if (typeof window !== "undefined" && url) window.location.assign(url);
177
+ } catch (e) {
178
+ setError(errMessage(e));
179
+ }
180
+ },
181
+ };
182
+ }
@@ -0,0 +1,213 @@
1
+ import { useCallback, useMemo, useState } from "react";
2
+ import { MissingCommunityProviderError, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
3
+ import {
4
+ useCommunityUpgradePreview,
5
+ useInvalidateCommunities,
6
+ useUpgradeCommunityTier,
7
+ type CommunityUpgradeResult,
8
+ type CommunityUpgradeTier,
9
+ type PublicTier,
10
+ } from "../../../data/queries/useCommunities";
11
+ import {
12
+ hasPaystackSession,
13
+ openPaystackCheckout as openPaystackModal,
14
+ type PaystackCheckoutOutcome,
15
+ type PaystackCheckoutSession,
16
+ } from "../../../utils/paystackCheckout";
17
+ import { useCommunity } from "./useCommunity";
18
+ import { communityHomePath } from "./paths";
19
+
20
+ /**
21
+ * Where the upgrade sheet is.
22
+ *
23
+ * choose the tiers above the current one
24
+ * confirm one tier chosen: its price now (prorated on Stripe) or the
25
+ * warning that the card is collected again (Paystack)
26
+ * submitting the upgrade request is in flight
27
+ * payment the provider needs the card: a Stripe secret to confirm, or a
28
+ * Paystack checkout to open
29
+ * done upgraded
30
+ */
31
+ export type CommunityUpgradeStep = "choose" | "confirm" | "submitting" | "payment" | "done";
32
+
33
+ export interface UseCommunityUpgradeOptions {
34
+ /** Where the provider returns after a card payment. Default: the community home with `confirmSubscription=1`. */
35
+ returnPath?: string;
36
+ /**
37
+ * Called once the upgrade is complete. Omitted, the fan is sent to the
38
+ * community home (`navigate`, or a full-page visit).
39
+ */
40
+ onUpgraded?: (result: { requiresConfirmation: boolean; path: string }) => void;
41
+ /** The host's router, used for the default landing. */
42
+ navigate?: (href: string) => void;
43
+ /** Open with this tier chosen (a locked item's "Unlock with {tier}"). */
44
+ initialTierId?: string | null;
45
+ }
46
+
47
+ const errMessage = (e: unknown) =>
48
+ (e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Something went wrong.";
49
+
50
+ export interface CommunityUpgradeOption extends CommunityUpgradeTier {
51
+ /** The full tier (benefits, description) when the community read carried it. */
52
+ tier: PublicTier | null;
53
+ }
54
+
55
+ /**
56
+ * The upgrade sheet: only the tiers ABOVE the viewer's current one in this
57
+ * community, the price of moving now, and `upgrade(tierId)`.
58
+ *
59
+ * Provider differences live here, not in markup:
60
+ *
61
+ * - **Stripe**: one tap on the saved card. The server swaps the price with
62
+ * proration and the answer is `changed`, with nothing to pay here. If the
63
+ * bank asks for authentication, the answer is `requires_payment` with a
64
+ * secret to confirm.
65
+ * - **Paystack**: the provider cannot swap a price, so the subscription is
66
+ * cancelled and recreated and the card is collected again (decision 10).
67
+ * `requiresCard` is true from the preview, so the sheet says so BEFORE the fan
68
+ * confirms, and the answer carries a checkout to open.
69
+ */
70
+ export function useCommunityUpgrade(opts: UseCommunityUpgradeOptions = {}) {
71
+ const context = useOptionalCommunityContext();
72
+ if (!context) throw new MissingCommunityProviderError("useCommunityUpgrade()");
73
+ const community = useCommunity();
74
+ const ref = community.slug ?? context.slug ?? context.ref;
75
+ const slug = community.slug ?? context.slug;
76
+ const mutation = useUpgradeCommunityTier(ref);
77
+ const invalidate = useInvalidateCommunities();
78
+
79
+ const [selectedTierId, setSelectedTierId] = useState<string | null>(opts.initialTierId ?? null);
80
+ const [result, setResult] = useState<CommunityUpgradeResult | null>(null);
81
+ const [paystackSession, setPaystackSession] = useState<PaystackCheckoutSession | null>(null);
82
+ const [done, setDone] = useState(false);
83
+ const [error, setError] = useState<string | null>(null);
84
+
85
+ const preview = useCommunityUpgradePreview(ref, selectedTierId ?? undefined);
86
+
87
+ const options: CommunityUpgradeOption[] = useMemo(
88
+ () =>
89
+ community.upgradeTiers.map((option) => ({
90
+ ...option,
91
+ tier: community.tiers.find((tier) => tier.id === option.id) ?? null,
92
+ })),
93
+ [community.upgradeTiers, community.tiers],
94
+ );
95
+ const selected = options.find((option) => option.id === selectedTierId) ?? null;
96
+
97
+ const origin = typeof window !== "undefined" ? window.location.origin : "";
98
+ const returnPath = opts.returnPath ?? (slug ? communityHomePath(slug, { confirmSubscription: 1 }) : "");
99
+ const landingPath = slug ? communityHomePath(slug) : returnPath;
100
+
101
+ const land = useCallback(
102
+ (requiresConfirmation: boolean) => {
103
+ setDone(true);
104
+ void invalidate();
105
+ const path = requiresConfirmation ? returnPath : landingPath;
106
+ if (opts.onUpgraded) return opts.onUpgraded({ requiresConfirmation, path });
107
+ if (opts.navigate) return opts.navigate(path);
108
+ if (typeof window !== "undefined" && path) window.location.assign(`${origin}${path}`);
109
+ },
110
+ // eslint-disable-next-line react-hooks/exhaustive-deps
111
+ [returnPath, landingPath, origin, opts.onUpgraded, opts.navigate],
112
+ );
113
+
114
+ const upgrade = async (tierId?: string) => {
115
+ const target = tierId ?? selectedTierId;
116
+ setError(null);
117
+ if (!target) {
118
+ setError("Choose a tier to upgrade to.");
119
+ return;
120
+ }
121
+ setSelectedTierId(target);
122
+ try {
123
+ const data = await mutation.mutateAsync({ membershipTierId: target, returnUrl: `${origin}${returnPath}` });
124
+ setResult(data);
125
+ if (data.status === "changed") {
126
+ land(false);
127
+ return;
128
+ }
129
+ if (data.accessCode || data.checkoutUrl) {
130
+ const session: PaystackCheckoutSession = {
131
+ accessCode: data.accessCode,
132
+ checkoutUrl: data.checkoutUrl,
133
+ returnUrl: `${origin}${returnPath}`,
134
+ };
135
+ setPaystackSession(session);
136
+ void openPaystackModal(session, {
137
+ onDismiss: () => {},
138
+ onError: (message) => setError(message ?? "The payment could not be completed. Please try again."),
139
+ });
140
+ }
141
+ } catch (e) {
142
+ setError(errMessage(e));
143
+ }
144
+ };
145
+
146
+ const step: CommunityUpgradeStep = done
147
+ ? "done"
148
+ : mutation.isPending
149
+ ? "submitting"
150
+ : result?.status === "requires_payment" && (result.clientSecret || hasPaystackSession(paystackSession))
151
+ ? "payment"
152
+ : selectedTierId
153
+ ? "confirm"
154
+ : "choose";
155
+
156
+ return {
157
+ step,
158
+ isLoading: community.isLoading,
159
+ /** Only the tiers above the current one. Empty means there is nothing to upgrade to. */
160
+ options,
161
+ currentTier: community.currentTier,
162
+ currentTierName: community.membership?.tierName ?? null,
163
+ selectedTierId,
164
+ selected,
165
+ /** Pick a tier (moves to `confirm`). */
166
+ select: (tierId: string) => {
167
+ setError(null);
168
+ setResult(null);
169
+ setPaystackSession(null);
170
+ setSelectedTierId(tierId);
171
+ },
172
+ /** Back to the list. */
173
+ back: () => {
174
+ setError(null);
175
+ setResult(null);
176
+ setPaystackSession(null);
177
+ setSelectedTierId(null);
178
+ },
179
+ /** What moving now costs, once a tier is chosen. */
180
+ preview: preview.data ?? null,
181
+ previewLoading: !!selectedTierId && preview.isLoading,
182
+ /** True on Paystack: the card is collected again and the subscription replaced. Say so before confirming. */
183
+ requiresCard: preview.data?.requiresCard ?? false,
184
+ prorated: preview.data?.prorated ?? false,
185
+ upgrade,
186
+ isSubmitting: mutation.isPending,
187
+ error,
188
+ /** STRIPE: the secret to confirm on the payment step. */
189
+ clientSecret: result?.clientSecret,
190
+ /** PAYSTACK: the upgrade's checkout is open or can be re-opened. */
191
+ isPaystack: !!paystackSession,
192
+ canOpenPaystack: hasPaystackSession(paystackSession),
193
+ openPaystackCheckout: (): Promise<PaystackCheckoutOutcome> =>
194
+ paystackSession
195
+ ? openPaystackModal(paystackSession, {
196
+ onDismiss: () => {},
197
+ onError: (message) => setError(message ?? "The payment could not be completed. Please try again."),
198
+ })
199
+ : Promise.resolve("unavailable" as PaystackCheckoutOutcome),
200
+ /** The renderer's `returnUrl` for the payment step. */
201
+ returnUrl: `${origin}${returnPath}`,
202
+ /** Call once the card renderer reports success. Lands on the community home to reconcile. */
203
+ onPaymentSucceeded: () => land(true),
204
+ /** Clear everything, e.g. when the sheet closes. */
205
+ reset: () => {
206
+ setError(null);
207
+ setResult(null);
208
+ setPaystackSession(null);
209
+ setSelectedTierId(null);
210
+ setDone(false);
211
+ },
212
+ };
213
+ }
@@ -0,0 +1,266 @@
1
+ import { useMemo, useState } from "react";
2
+ import { useCommunityUpgradeOpener, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
3
+ import { isNotFound } from "../../../data/queries/useCommunities";
4
+ import {
5
+ useCommunityWikiMediaUrl,
6
+ useCommunityWikiPageContent,
7
+ useCommunityWikiPages,
8
+ type CommunityWikiPageSummary,
9
+ } from "../../../data/queries/useCommunityWiki";
10
+ import type { LexicalNodeJson } from "../community/lexical";
11
+ import { communityAboutPath } from "./paths";
12
+
13
+ // ── Pure helpers (unit-tested; no React) ─────────────────────────────────────
14
+
15
+ /** How a wiki image's `src` is shown, if at all. */
16
+ export type WikiImageSource = { kind: "url"; url: string } | { kind: "private"; mediaId: string } | null;
17
+
18
+ const PRIVATE_MEDIA_PREFIX = "kbmedia:";
19
+ const MEDIA_ID = /^[A-Za-z0-9_-]{1,128}$/;
20
+
21
+ /**
22
+ * Read a Knowledge Base image's `src`. A plain http(s) address (or a site
23
+ * path) is shown as it is. `kbmedia:<id>` is a PRIVATE image, shown through a
24
+ * short-lived signed read that only a viewer of the page can get. Anything
25
+ * else is never rendered: not `javascript:`, not `data:`, not a malformed id.
26
+ */
27
+ export function wikiImageSource(src: unknown): WikiImageSource {
28
+ if (typeof src !== "string") return null;
29
+ const value = src.trim();
30
+ if (/^https?:\/\//i.test(value) || (value.startsWith("/") && !value.startsWith("//")))
31
+ return { kind: "url", url: value };
32
+ if (value.startsWith(PRIVATE_MEDIA_PREFIX)) {
33
+ const mediaId = value.slice(PRIVATE_MEDIA_PREFIX.length);
34
+ return MEDIA_ID.test(mediaId) ? { kind: "private", mediaId } : null;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ export type WikiMediaImageStatus = "loading" | "ready" | "unavailable";
40
+
41
+ /**
42
+ * Where a private image stands, from the signed-read request and the addresses
43
+ * the browser failed to load. The first failure (usually an expired address)
44
+ * asks for a fresh one; once that re-request has been made
45
+ * (`retriedAfterFailure`), a failed address reads as unavailable, so a broken
46
+ * file cannot loop.
47
+ */
48
+ export function wikiMediaImageStatus(input: {
49
+ isLoading: boolean;
50
+ isError: boolean;
51
+ url: string | null;
52
+ failedUrls: ReadonlySet<string>;
53
+ retriedAfterFailure: boolean;
54
+ }): WikiMediaImageStatus {
55
+ if (input.isError) return "unavailable";
56
+ if (!input.url) return input.isLoading ? "loading" : "unavailable";
57
+ if (input.failedUrls.has(input.url)) return input.retriedAfterFailure ? "unavailable" : "loading";
58
+ return "ready";
59
+ }
60
+
61
+ /** One entry of a page's table of contents. `id` is the heading's anchor. */
62
+ export interface CommunityWikiTocEntry {
63
+ id: string;
64
+ text: string;
65
+ /** 1 for h1 through 6 for h6. */
66
+ level: number;
67
+ }
68
+
69
+ /** A table of contents is shown once a page has this many headings. */
70
+ export const WIKI_TOC_MIN_HEADINGS = 2;
71
+
72
+ /**
73
+ * The page list split the way a renderer draws it: every page in order, the
74
+ * ones this viewer can open, and the ones listed but locked to a tier they do
75
+ * not have. `publicOnly` keeps just the public pages (the About page, which a
76
+ * visitor and a member see alike).
77
+ */
78
+ export function shapeWikiList(pages: CommunityWikiPageSummary[] | undefined, opts: { publicOnly?: boolean } = {}) {
79
+ const all = (Array.isArray(pages) ? pages : []).filter((page) => !opts.publicOnly || page.isPublic);
80
+ return {
81
+ all,
82
+ readable: all.filter((page) => !page.locked),
83
+ locked: all.filter((page) => page.locked),
84
+ };
85
+ }
86
+
87
+ /**
88
+ * What opens a locked page, for the copy beside it: the level the member has
89
+ * not reached yet (null when the level is not what is missing) and the names
90
+ * of the tiers that open it (empty when none are named).
91
+ */
92
+ export function wikiUnlockParts(page: Pick<CommunityWikiPageSummary, "unlockLevel" | "unlockTiers">): {
93
+ level: number | null;
94
+ tiers: string;
95
+ } {
96
+ const level = typeof page.unlockLevel === "number" && page.unlockLevel > 0 ? page.unlockLevel : null;
97
+ const tiers = (page.unlockTiers ?? []).map((tier) => tier.name).filter(Boolean).join(", ");
98
+ return { level, tiers };
99
+ }
100
+
101
+ /**
102
+ * The page the Wiki tab opens on when the address names none: the first page
103
+ * this viewer can read, in the wiki's order. Null when every page is locked or
104
+ * there are none.
105
+ */
106
+ export function defaultWikiPageSlug(pages: CommunityWikiPageSummary[] | undefined): string | null {
107
+ return (Array.isArray(pages) ? pages : []).find((page) => !page.locked)?.slug ?? null;
108
+ }
109
+
110
+ /**
111
+ * Lexical JSON as a root node. The Knowledge Base stores the editor state as an
112
+ * object, but a string of JSON is accepted too, so a page never renders empty
113
+ * because of how its content was serialised.
114
+ */
115
+ export function parseWikiContent(content: unknown): { root: LexicalNodeJson } | null {
116
+ let value = content;
117
+ if (typeof value === "string") {
118
+ try {
119
+ value = JSON.parse(value);
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+ if (!value || typeof value !== "object") return null;
125
+ const maybe = value as { root?: LexicalNodeJson; children?: LexicalNodeJson[] };
126
+ if (maybe.root && typeof maybe.root === "object") return { root: maybe.root };
127
+ if (Array.isArray(maybe.children)) return { root: maybe as LexicalNodeJson };
128
+ return null;
129
+ }
130
+
131
+ /** The plain text of a node and everything under it. */
132
+ export function lexicalNodeText(node: LexicalNodeJson): string {
133
+ if (typeof node.text === "string") return node.text;
134
+ return (node.children ?? []).map(lexicalNodeText).join("");
135
+ }
136
+
137
+ /** A URL-safe anchor for a heading ("Getting started!" to "getting-started"). */
138
+ export function wikiHeadingSlug(text: string): string {
139
+ const slug = text
140
+ .normalize("NFKD")
141
+ // Drop the accents NFKD split off ("Über" to "Uber").
142
+ .replace(/\p{M}/gu, "")
143
+ .toLowerCase()
144
+ .replace(/[^\p{L}\p{N}]+/gu, "-")
145
+ .replace(/^-+|-+$/g, "");
146
+ return slug || "section";
147
+ }
148
+
149
+ /**
150
+ * The table of contents: every top-level heading in document order, each with
151
+ * a unique anchor (a repeated title gets `-2`, `-3`). The renderer gives the
152
+ * headings these same ids in the same order, which is why both read the root's
153
+ * direct children only. Empty headings are skipped.
154
+ */
155
+ export function extractWikiToc(content: unknown): CommunityWikiTocEntry[] {
156
+ const parsed = parseWikiContent(content);
157
+ if (!parsed) return [];
158
+ const used = new Map<string, number>();
159
+ const toc: CommunityWikiTocEntry[] = [];
160
+ for (const node of parsed.root.children ?? []) {
161
+ if (node.type !== "heading") continue;
162
+ const text = lexicalNodeText(node).trim();
163
+ if (!text) continue;
164
+ const base = wikiHeadingSlug(text);
165
+ const seen = used.get(base) ?? 0;
166
+ used.set(base, seen + 1);
167
+ const tag = typeof node.tag === "string" ? node.tag : "h2";
168
+ const level = Math.min(6, Math.max(1, Number(tag.replace(/^h/, "")) || 2));
169
+ toc.push({ id: seen ? `${base}-${seen + 1}` : base, text, level });
170
+ }
171
+ return toc;
172
+ }
173
+
174
+ // ── Hooks ────────────────────────────────────────────────────────────────────
175
+
176
+ /**
177
+ * A PRIVATE wiki image (`kbmedia:<id>`) on the page `pageSlug`: its signed
178
+ * address, re-requested before it expires, and again once if the browser
179
+ * fails to load it (`onImageError`). Only the wiki page view uses this: posts
180
+ * never carry private Knowledge Base media.
181
+ */
182
+ export function useWikiMediaImage(pageSlug: string | undefined, mediaId: string | undefined) {
183
+ const query = useCommunityWikiMediaUrl(pageSlug, mediaId);
184
+ const [failedUrls, setFailedUrls] = useState<ReadonlySet<string>>(() => new Set());
185
+ const [retriedAfterFailure, setRetriedAfterFailure] = useState(false);
186
+ const url = query.data?.url ?? null;
187
+ const status = wikiMediaImageStatus({
188
+ isLoading: query.isLoading || query.isFetching,
189
+ isError: query.isError,
190
+ url,
191
+ failedUrls,
192
+ retriedAfterFailure,
193
+ });
194
+
195
+ return {
196
+ status,
197
+ url: status === "ready" ? url : null,
198
+ /** Call from the <img>'s onError: an expired address is replaced once. */
199
+ onImageError: () => {
200
+ if (!url || failedUrls.has(url)) return;
201
+ setFailedUrls((prev) => new Set(prev).add(url));
202
+ if (retriedAfterFailure) return;
203
+ void query.refetch().finally(() => setRetriedAfterFailure(true));
204
+ },
205
+ };
206
+ }
207
+
208
+ export interface UseCommunityWikiOptions {
209
+ /** Keep only the public pages (the About page). */
210
+ publicOnly?: boolean;
211
+ enabled?: boolean;
212
+ }
213
+
214
+ /**
215
+ * Headless wiki page list for the surrounding community: every page this
216
+ * viewer may see, the readable and locked ones, the page to open by default,
217
+ * and how to unlock a locked one. `unlock` opens the community's upgrade sheet
218
+ * with the page's first unlocking tier when a community home provides one;
219
+ * otherwise `unlockHref` is the About page with that tier preselected.
220
+ */
221
+ export function useCommunityWiki(opts: UseCommunityWikiOptions = {}) {
222
+ const community = useOptionalCommunityContext();
223
+ const opener = useCommunityUpgradeOpener();
224
+ const query = useCommunityWikiPages({ enabled: opts.enabled });
225
+ const shaped = useMemo(
226
+ () => shapeWikiList(query.data, { publicOnly: opts.publicOnly }),
227
+ [query.data, opts.publicOnly],
228
+ );
229
+
230
+ return {
231
+ pages: shaped.all,
232
+ readablePages: shaped.readable,
233
+ lockedPages: shaped.locked,
234
+ /** The page to open when the address names none. */
235
+ defaultSlug: defaultWikiPageSlug(shaped.all),
236
+ isLoading: query.isLoading,
237
+ isError: query.isError && !isNotFound(query.error),
238
+ /** Open the upgrade sheet for a locked page, when a community home provides one. */
239
+ unlock: opener ? (page: CommunityWikiPageSummary) => opener.openUpgrade(page.unlockTiers[0]?.id ?? null) : null,
240
+ /** Where a locked page's upgrade leads when no upgrade sheet is on hand. */
241
+ unlockHref: (page: CommunityWikiPageSummary) =>
242
+ community?.slug ? communityAboutPath(community.slug, { tier: page.unlockTiers[0]?.id }) : null,
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Headless wiki page: its content (parsed), the table of contents and whether
248
+ * to show it, and `notFound` when this viewer may not read the page (locked,
249
+ * hidden or gone: the server does not say which, by design).
250
+ */
251
+ export function useCommunityWikiPage(pageSlug: string | undefined, opts: { enabled?: boolean } = {}) {
252
+ const query = useCommunityWikiPageContent(pageSlug, opts);
253
+ const content = useMemo(() => parseWikiContent(query.data?.content), [query.data]);
254
+ const toc = useMemo(() => extractWikiToc(content), [content]);
255
+ return {
256
+ page: query.data ?? null,
257
+ content,
258
+ toc,
259
+ showToc: toc.length >= WIKI_TOC_MIN_HEADINGS,
260
+ /** Anchor ids for the page's headings, in order, for the renderer. */
261
+ headingIds: toc.map((entry) => entry.id),
262
+ isLoading: query.isLoading,
263
+ notFound: isNotFound(query.error),
264
+ isError: query.isError && !isNotFound(query.error),
265
+ };
266
+ }