@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,289 @@
1
+ import type { CSSProperties, MouseEvent } from "react";
2
+ import { BookOpen, FileText, Lock } from "lucide-react";
3
+ import { useForgeT } from "../../../i18n";
4
+ import {
5
+ useCommunityWiki,
6
+ useCommunityWikiPage,
7
+ useWikiMediaImage,
8
+ wikiUnlockParts,
9
+ } from "../../headless/communities/useCommunityWiki";
10
+ import type { CommunityWikiPageSummary } from "../../../data/queries/useCommunityWiki";
11
+ import { CommunityRichText, type CommunityRichTextPrivateImage } from "../community/CommunityRichText";
12
+ import { Loading } from "../Loading";
13
+ import { useCommunityStyles } from "./shared";
14
+
15
+ /** The line that says what opens a locked page: a level, tiers, or both. */
16
+ function useUnlockCopy() {
17
+ const tr = useForgeT();
18
+ return (page: CommunityWikiPageSummary): string => {
19
+ const { level, tiers } = wikiUnlockParts(page);
20
+ if (level !== null && tiers) return tr("forge.community_wiki.unlocks_at_level_with", { level, tiers });
21
+ if (level !== null) return tr("forge.community_wiki.unlocks_at_level", { level });
22
+ return tiers ? tr("forge.community_wiki.unlocks_with", { tiers }) : tr("forge.community_wiki.locked_no_tier");
23
+ };
24
+ }
25
+
26
+ export interface CommunityWikiProps {
27
+ /** The page in the address (`/home/wiki/<page>`). Omitted, the first page this viewer can read opens. */
28
+ pageSlug?: string;
29
+ /** The address of a page, for the list's links. */
30
+ getPageHref: (pageSlug: string) => string;
31
+ /** SPA navigation adapter. Defaults to a plain link. */
32
+ navigate?: (href: string) => void;
33
+ className?: string;
34
+ style?: CSSProperties;
35
+ }
36
+
37
+ /**
38
+ * The Wiki tab of a community's member home: the page list beside the open
39
+ * page. Pages a member's tier does not open are listed with a lock, the tiers
40
+ * that open them and an upgrade button (the community's upgrade sheet when a
41
+ * community home provides one, else the About page with the tier chosen).
42
+ * Must sit inside `<CommunityProvider>`. Built on `useCommunityWiki`.
43
+ */
44
+ export function CommunityWiki({ pageSlug, getPageHref, navigate, className, style }: CommunityWikiProps) {
45
+ const tr = useForgeT();
46
+ const { t, card, muted } = useCommunityStyles();
47
+ const wiki = useCommunityWiki();
48
+ const unlockCopy = useUnlockCopy();
49
+
50
+ if (wiki.isLoading) return <Loading />;
51
+ if (wiki.isError) {
52
+ return (
53
+ <p role="alert" className={className} style={{ color: t.text, ...style }}>
54
+ {tr("forge.community_wiki.error")}
55
+ </p>
56
+ );
57
+ }
58
+ if (wiki.pages.length === 0) {
59
+ return (
60
+ <div className={className} style={{ ...card, textAlign: "center", color: t.muted, ...style }}>
61
+ {tr("forge.community_wiki.empty")}
62
+ </div>
63
+ );
64
+ }
65
+
66
+ const activeSlug = pageSlug ?? wiki.defaultSlug;
67
+ const activeLocked = wiki.lockedPages.find((page) => page.slug === activeSlug) ?? null;
68
+
69
+ const open = (e: MouseEvent<HTMLAnchorElement>, href: string) => {
70
+ if (!navigate) return;
71
+ e.preventDefault();
72
+ navigate(href);
73
+ };
74
+
75
+ return (
76
+ <div
77
+ data-testid="community-wiki"
78
+ className={className}
79
+ style={{ color: t.text, fontFamily: t.fontFamily, ...style }}
80
+ >
81
+ <div className="fg:flex fg:flex-col fg:gap-5 fg:md:flex-row fg:md:items-start">
82
+ <nav aria-label={tr("forge.community_wiki.nav_label")} className="fg:w-full fg:md:w-[240px] fg:md:shrink-0">
83
+ <h2
84
+ className="fg:flex fg:items-center fg:gap-2"
85
+ style={{ margin: "0 0 10px", fontSize: 16, fontFamily: t.headingFontFamily }}
86
+ >
87
+ <BookOpen size={16} style={{ color: t.primary }} aria-hidden /> {tr("forge.community_wiki.title")}
88
+ </h2>
89
+ <ul className="fg:flex fg:flex-col fg:gap-1" style={{ listStyle: "none", margin: 0, padding: 0 }}>
90
+ {wiki.pages.map((page) => {
91
+ const active = page.slug === activeSlug;
92
+ const href = getPageHref(page.slug);
93
+ return (
94
+ <li key={page.id} data-testid="community-wiki-item">
95
+ <a
96
+ href={href}
97
+ onClick={(e) => open(e, href)}
98
+ aria-current={active ? "page" : undefined}
99
+ className="fg:flex fg:items-center fg:gap-2"
100
+ style={{
101
+ padding: "7px 10px",
102
+ borderRadius: t.cornerRadius,
103
+ textDecoration: "none",
104
+ fontSize: 14,
105
+ fontWeight: active ? 700 : 500,
106
+ color: page.locked ? t.muted : active ? t.primary : t.text,
107
+ background: active ? `${t.primary}14` : "transparent",
108
+ }}
109
+ >
110
+ {page.locked ? <Lock size={13} aria-hidden /> : <FileText size={13} aria-hidden />}
111
+ <span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
112
+ {page.title}
113
+ </span>
114
+ {page.locked && <span className="fg:sr-only">{tr("forge.community_wiki.locked_label")}</span>}
115
+ </a>
116
+ {page.locked && wikiUnlockParts(page).level !== null && (
117
+ <span
118
+ data-testid="community-wiki-item-level"
119
+ style={{ display: "block", padding: "0 10px 4px 31px", fontSize: 11, color: t.muted }}
120
+ >
121
+ {unlockCopy(page)}
122
+ </span>
123
+ )}
124
+ </li>
125
+ );
126
+ })}
127
+ </ul>
128
+ </nav>
129
+
130
+ <div className="fg:min-w-0 fg:flex-1">
131
+ {activeLocked ? (
132
+ <LockedWikiPage page={activeLocked} wiki={wiki} />
133
+ ) : activeSlug ? (
134
+ <CommunityWikiPageView pageSlug={activeSlug} />
135
+ ) : (
136
+ <p style={muted}>{tr("forge.community_wiki.all_locked")}</p>
137
+ )}
138
+ </div>
139
+ </div>
140
+ </div>
141
+ );
142
+ }
143
+
144
+ /** What a member sees in place of a page their tier does not open. */
145
+ function LockedWikiPage({ page, wiki }: { page: CommunityWikiPageSummary; wiki: ReturnType<typeof useCommunityWiki> }) {
146
+ const tr = useForgeT();
147
+ const { t, card, button } = useCommunityStyles();
148
+ const unlockCopy = useUnlockCopy();
149
+ const { level, tiers } = wikiUnlockParts(page);
150
+ // A page only the member's level holds back has nothing to buy: no upgrade.
151
+ const offerUpgrade = level === null || !!tiers;
152
+ const href = wiki.unlockHref(page);
153
+ return (
154
+ <div data-testid="community-wiki-locked" className="fg:flex fg:flex-col fg:items-start fg:gap-3" style={card}>
155
+ <h1
156
+ className="fg:flex fg:items-center fg:gap-2"
157
+ style={{ margin: 0, fontSize: 22, fontFamily: t.headingFontFamily }}
158
+ >
159
+ <Lock size={18} style={{ color: t.muted }} aria-hidden /> {page.title}
160
+ </h1>
161
+ <p data-testid="community-wiki-unlock" style={{ margin: 0 }}>
162
+ {unlockCopy(page)}
163
+ </p>
164
+ {!offerUpgrade ? null : wiki.unlock ? (
165
+ <button type="button" data-testid="community-wiki-upgrade" style={button} onClick={() => wiki.unlock?.(page)}>
166
+ {tr("forge.community_wiki.upgrade")}
167
+ </button>
168
+ ) : href ? (
169
+ <a data-testid="community-wiki-upgrade" href={href} style={{ ...button, textDecoration: "none" }}>
170
+ {tr("forge.community_wiki.upgrade")}
171
+ </a>
172
+ ) : null}
173
+ </div>
174
+ );
175
+ }
176
+
177
+ export interface CommunityWikiPageViewProps {
178
+ pageSlug: string;
179
+ /** Hide the page title (when a surrounding section already shows it). */
180
+ hideTitle?: boolean;
181
+ className?: string;
182
+ style?: CSSProperties;
183
+ }
184
+
185
+ /**
186
+ * One wiki page: its title, a table of contents once it has two or more
187
+ * headings, and its content. A page this viewer may not read shows a notice.
188
+ * Built on `useCommunityWikiPage`.
189
+ */
190
+ export function CommunityWikiPageView({ pageSlug, hideTitle, className, style }: CommunityWikiPageViewProps) {
191
+ const tr = useForgeT();
192
+ const { t, card, muted } = useCommunityStyles();
193
+ const view = useCommunityWikiPage(pageSlug);
194
+
195
+ if (view.isLoading) return <Loading />;
196
+ if (view.notFound || !view.page) {
197
+ return (
198
+ <div className={className} style={{ ...card, color: t.muted, ...style }}>
199
+ {tr(view.isError ? "forge.community_wiki.error" : "forge.community_wiki.page_unavailable")}
200
+ </div>
201
+ );
202
+ }
203
+
204
+ return (
205
+ <article data-testid="community-wiki-page" className={className} style={{ color: t.text, ...style }}>
206
+ {!hideTitle && (
207
+ <h1 style={{ margin: "0 0 12px", fontSize: 26, fontWeight: 800, fontFamily: t.headingFontFamily }}>
208
+ {view.page.title}
209
+ </h1>
210
+ )}
211
+ {view.showToc && (
212
+ <nav
213
+ aria-label={tr("forge.community_wiki.toc_label")}
214
+ data-testid="community-wiki-toc"
215
+ style={{ ...card, padding: "12px 16px", marginBottom: 16 }}
216
+ >
217
+ <p style={{ ...muted, margin: "0 0 6px", fontWeight: 700 }}>{tr("forge.community_wiki.toc_title")}</p>
218
+ <ol
219
+ className="fg:flex fg:flex-col fg:gap-1"
220
+ style={{ listStyle: "none", margin: 0, padding: 0, fontSize: 14 }}
221
+ >
222
+ {view.toc.map((entry) => (
223
+ <li key={entry.id} style={{ paddingLeft: Math.max(0, entry.level - 1) * 12 }}>
224
+ <a href={`#${entry.id}`} style={{ color: t.primary, textDecoration: "none" }}>
225
+ {entry.text}
226
+ </a>
227
+ </li>
228
+ ))}
229
+ </ol>
230
+ </nav>
231
+ )}
232
+ <CommunityRichText
233
+ body={view.content}
234
+ headingIds={view.headingIds}
235
+ renderPrivateImage={(image) => <WikiMediaImage pageSlug={pageSlug} {...image} />}
236
+ />
237
+ </article>
238
+ );
239
+ }
240
+
241
+ /**
242
+ * A PRIVATE wiki image: a placeholder while its signed address is fetched, the
243
+ * image once it is, and a quiet "image unavailable" when this viewer may not
244
+ * see it or it cannot be loaded. An expired address is replaced once.
245
+ */
246
+ function WikiMediaImage({ pageSlug, mediaId, alt, inline }: { pageSlug: string } & CommunityRichTextPrivateImage) {
247
+ const tr = useForgeT();
248
+ const { t } = useCommunityStyles();
249
+ const image = useWikiMediaImage(pageSlug, mediaId);
250
+ const block: CSSProperties = inline
251
+ ? { display: "inline-block", verticalAlign: "middle" }
252
+ : { display: "block", margin: "0.6em 0" };
253
+
254
+ if (image.status === "ready" && image.url) {
255
+ return (
256
+ <img
257
+ data-testid="community-wiki-private-image"
258
+ src={image.url}
259
+ alt={alt}
260
+ loading="lazy"
261
+ onError={image.onImageError}
262
+ style={{ ...block, maxWidth: "100%", height: "auto", borderRadius: t.cornerRadius }}
263
+ />
264
+ );
265
+ }
266
+ if (image.status === "loading") {
267
+ return (
268
+ <span
269
+ data-testid="community-wiki-image-placeholder"
270
+ role="img"
271
+ aria-label={alt || tr("forge.community_wiki.image_loading")}
272
+ aria-busy="true"
273
+ style={{
274
+ ...block,
275
+ width: inline ? 120 : "100%",
276
+ maxWidth: 480,
277
+ height: inline ? 80 : 200,
278
+ borderRadius: t.cornerRadius,
279
+ background: `${t.text}0d`,
280
+ }}
281
+ />
282
+ );
283
+ }
284
+ return (
285
+ <span data-testid="community-wiki-image-unavailable" style={{ ...block, fontSize: 13, color: t.muted }}>
286
+ {tr("forge.community_wiki.image_unavailable")}
287
+ </span>
288
+ );
289
+ }
@@ -0,0 +1,115 @@
1
+ import type { CSSProperties } from "react";
2
+ import { useMyMemberships } from "../../headless/communities/useMyMemberships";
3
+ import { getMembershipStatusMessage } from "../../../utils/membershipAccess";
4
+ import { useForgeLocale, useForgeT } from "../../../i18n";
5
+ import { useAmountFormatter } from "../../format/useFormatCurrency";
6
+ import { Loading } from "../Loading";
7
+ import { Pill, shortDate, useCommunityStyles } from "./shared";
8
+
9
+ export interface MyMembershipsListProps {
10
+ /** SPA navigation adapter for "Open community". Defaults to a full-page visit. */
11
+ navigate?: (href: string) => void;
12
+ /** Format an amount in a currency. Defaults to the runtime currency formatter. */
13
+ formatAmount?: (amount: number, currencyCode?: string) => string;
14
+ /**
15
+ * `cards` (the default) boxes each membership. `rows` divides them with
16
+ * hairlines instead, the client portal's list style.
17
+ */
18
+ layout?: "cards" | "rows";
19
+ className?: string;
20
+ style?: CSSProperties;
21
+ }
22
+
23
+ /**
24
+ * Every membership the fan holds with this creator, one card per community,
25
+ * for the account page. Managing one (billing, cancel, downgrade) happens
26
+ * inside its community, so each card links there. Built on `useMyMemberships`.
27
+ */
28
+ export function MyMembershipsList({ navigate, formatAmount, layout = "cards", className, style }: MyMembershipsListProps) {
29
+ const tr = useForgeT();
30
+ const locale = useForgeLocale();
31
+ const { t, card, ghostButton, muted } = useCommunityStyles();
32
+ const fmt = useAmountFormatter(formatAmount);
33
+ const { memberships, isLoading } = useMyMemberships();
34
+ const go = navigate ?? ((href: string) => window.location.assign(href));
35
+ const rows = layout === "rows";
36
+ const itemStyle: CSSProperties = rows ? { padding: "20px 0", borderBottom: `1px solid ${t.border}` } : card;
37
+
38
+ if (isLoading) return <Loading />;
39
+ if (memberships.length === 0) {
40
+ return (
41
+ <p
42
+ className={className}
43
+ data-testid="my-memberships-empty"
44
+ style={{
45
+ ...muted,
46
+ ...(rows
47
+ ? { padding: "36px 24px", border: `1px dashed ${t.border}`, borderRadius: t.cornerRadius, textAlign: "center" }
48
+ : {}),
49
+ ...style,
50
+ }}
51
+ >
52
+ {tr("forge.my_memberships_list.empty")}
53
+ </p>
54
+ );
55
+ }
56
+
57
+ return (
58
+ <ul
59
+ className={className}
60
+ data-testid="my-memberships"
61
+ style={{
62
+ listStyle: "none",
63
+ padding: 0,
64
+ margin: 0,
65
+ display: "flex",
66
+ flexDirection: "column",
67
+ gap: rows ? 0 : 12,
68
+ ...(rows ? { borderTop: `1px solid ${t.border}` } : {}),
69
+ ...style,
70
+ }}
71
+ >
72
+ {memberships.map((membership) => {
73
+ const message = getMembershipStatusMessage(membership.access);
74
+ const periodEnd = shortDate(membership.periodEnd, locale);
75
+ return (
76
+ <li key={membership.id} data-testid="my-membership" className="fg:flex fg:flex-col fg:gap-2 fg:sm:flex-row fg:sm:items-center fg:sm:justify-between" style={itemStyle}>
77
+ <div className="fg:min-w-0">
78
+ <p style={{ fontWeight: rows ? 600 : 700, fontSize: rows ? 16 : undefined, margin: 0 }}>{membership.community.name}</p>
79
+ <div className="fg:flex fg:flex-wrap fg:items-center fg:gap-2" style={{ marginTop: 4 }}>
80
+ {membership.tierName && <Pill>{membership.tierName}</Pill>}
81
+ <span style={{ fontSize: 13, fontWeight: message.tone === "warning" ? 700 : 400, color: message.tone === "warning" ? t.primary : t.muted }}>
82
+ {message.label}
83
+ </span>
84
+ </div>
85
+ {!!membership.amount && (
86
+ <p style={{ ...muted, marginTop: 4 }}>
87
+ {tr("forge.my_memberships_list.price", {
88
+ amount: fmt(membership.amount, membership.currency ?? undefined),
89
+ cycle: membership.billingCycle === "year" ? tr("forge.my_memberships_list.cycle_year") : tr("forge.my_memberships_list.cycle_month"),
90
+ })}
91
+ </p>
92
+ )}
93
+ {membership.inTrial && membership.trialEndsAt ? (
94
+ <p data-testid="my-membership-trial" style={{ ...muted, fontWeight: 600 }}>
95
+ {tr("forge.my_memberships_list.trial_until", { date: shortDate(membership.trialEndsAt, locale) })}
96
+ </p>
97
+ ) : (
98
+ periodEnd && (
99
+ <p style={muted}>
100
+ {membership.cancelAtPeriodEnd
101
+ ? tr("forge.my_memberships_list.ends_on", { date: periodEnd })
102
+ : tr("forge.my_memberships_list.renews_on", { date: periodEnd })}
103
+ </p>
104
+ )
105
+ )}
106
+ </div>
107
+ <button type="button" style={{ ...ghostButton, flexShrink: 0 }} onClick={() => go(membership.homePath)}>
108
+ {tr("forge.my_memberships_list.open")}
109
+ </button>
110
+ </li>
111
+ );
112
+ })}
113
+ </ul>
114
+ );
115
+ }
@@ -0,0 +1,92 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
4
+ import type { ReactNode } from "react";
5
+ import { ForgeThemeProvider } from "../../../theme/ForgeThemeProvider";
6
+ import { CommunityHome } from "../CommunityHome";
7
+ import {
8
+ account,
9
+ detailRoute,
10
+ get,
11
+ installAdapter,
12
+ meRoute,
13
+ restoreAdapter,
14
+ viewer,
15
+ wrapper,
16
+ } from "../../../headless/_tests/communityHarness";
17
+ import { siteApp } from "../../../headless/_tests/siteAppHarness";
18
+
19
+ /**
20
+ * The Home rule inside a community: the site's mark at the start of both the
21
+ * phone and the desktop header leads to the app's Home, which is the
22
+ * creator's start page when they chose one and `/i/app` otherwise.
23
+ */
24
+
25
+ afterEach(restoreAdapter);
26
+
27
+ const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
28
+
29
+ function mount(node: ReactNode) {
30
+ const Wrapper = wrapper({ user: account() });
31
+ return render(
32
+ <Wrapper>
33
+ <ForgeThemeProvider theme={theme}>{node}</ForgeThemeProvider>
34
+ </Wrapper>,
35
+ );
36
+ }
37
+
38
+ const member = viewer({
39
+ state: "member",
40
+ membership: {
41
+ id: "m-1",
42
+ tierId: "tier-free",
43
+ tierName: "Free",
44
+ status: "active",
45
+ cancelAtPeriodEnd: false,
46
+ currentPeriodEnd: null,
47
+ pendingDowngradeTierId: null,
48
+ },
49
+ });
50
+
51
+ const routes = (app: () => unknown) => [
52
+ get("/public/websites/app", app),
53
+ meRoute(() => member),
54
+ detailRoute(() => ({ viewer: member })),
55
+ get("/public/memberships/mine", () => []),
56
+ ];
57
+
58
+ describe("CommunityHome and the app's Home", () => {
59
+ it("leads both headers' site mark to the creator's own start page", async () => {
60
+ installAdapter(routes(() => siteApp({ startUrl: "/members", iconUrl: "https://cdn.example/icon.png" })));
61
+ const navigate = vi.fn();
62
+ mount(
63
+ <CommunityHome tab="posts" navigate={navigate}>
64
+ <p>feed</p>
65
+ </CommunityHome>,
66
+ );
67
+ const compact = await screen.findByTestId("community-home-app-home-compact");
68
+ const full = screen.getByTestId("community-home-app-home");
69
+ await waitFor(() => expect(compact.getAttribute("href")).toBe("/members"));
70
+ expect(full.getAttribute("href")).toBe("/members");
71
+ expect(compact.querySelector("img")?.getAttribute("src")).toBe("https://cdn.example/icon.png");
72
+ expect(compact.getAttribute("aria-label")).toBe("Home");
73
+
74
+ fireEvent.click(full, { button: 0 });
75
+ expect(navigate).toHaveBeenCalledWith("/members");
76
+ });
77
+
78
+ it("falls back to /i/app when the site has no app settings", async () => {
79
+ installAdapter(
80
+ routes(() => {
81
+ throw { status: 404 };
82
+ }),
83
+ );
84
+ mount(
85
+ <CommunityHome tab="posts">
86
+ <p>feed</p>
87
+ </CommunityHome>,
88
+ );
89
+ expect((await screen.findByTestId("community-home-app-home-compact")).getAttribute("href")).toBe("/i/app");
90
+ expect(screen.getByTestId("community-home-app-home").getAttribute("href")).toBe("/i/app");
91
+ });
92
+ });