@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,399 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
4
+ import { ForgeThemeProvider } from "../../../theme/ForgeThemeProvider";
5
+ import { CommunityUpgradeOpenerProvider } from "../../../../contexts/CommunityContext";
6
+ import { CommunityHome } from "../CommunityHome";
7
+ import { CommunityMembershipMenu } from "../CommunityMembershipMenu";
8
+ import { CommunityJoinSheet } from "../CommunityJoinSheet";
9
+ import {
10
+ COMMUNITY,
11
+ account,
12
+ calls,
13
+ detailRoute,
14
+ get,
15
+ installAdapter,
16
+ meRoute,
17
+ post,
18
+ restoreAdapter,
19
+ tier,
20
+ viewer,
21
+ wrapper,
22
+ type Route,
23
+ } from "../../../headless/_tests/communityHarness";
24
+ import type { CommunityViewerState } from "../../../../data/queries/useCommunities";
25
+
26
+ /**
27
+ * The member area on a phone: a slim header whose tier pill opens the
28
+ * membership sheet, a bottom bar with "More", every sheet a bottom sheet below
29
+ * `lg`, the membership sheet's summary card and action rows, and the join
30
+ * sheet's email-first identity step.
31
+ *
32
+ * jsdom applies no CSS, so the responsive split is asserted through the
33
+ * classes that carry it (both headers render; which one shows is the class).
34
+ */
35
+
36
+ afterEach(restoreAdapter);
37
+
38
+ const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
39
+
40
+ function mount(node: React.ReactNode, opts: Parameters<typeof wrapper>[0] = {}) {
41
+ const Wrapper = wrapper(opts);
42
+ return render(
43
+ <Wrapper>
44
+ <ForgeThemeProvider theme={theme}>{node}</ForgeThemeProvider>
45
+ </Wrapper>,
46
+ );
47
+ }
48
+
49
+ const free = tier({ id: "tier-free", name: "Free" });
50
+ const gold = tier({ id: "tier-gold", name: "Gold", priceMonthly: 20 });
51
+ const basic = tier({ id: "tier-basic", name: "Basic", priceMonthly: 5 });
52
+
53
+ const member = (tierId: string, tierName: string, patch: Partial<NonNullable<CommunityViewerState["membership"]>> = {}, upgrade = false) =>
54
+ viewer({
55
+ state: "member",
56
+ membership: {
57
+ id: "m-1",
58
+ tierId,
59
+ tierName,
60
+ status: "active",
61
+ cancelAtPeriodEnd: false,
62
+ currentPeriodEnd: null,
63
+ pendingDowngradeTierId: null,
64
+ ...patch,
65
+ },
66
+ upgradeTiers: upgrade ? [{ id: "tier-gold", name: "Gold", priceMonthly: 20, priceYearly: null }] : [],
67
+ });
68
+
69
+ /** `/public/memberships/mine` for the member's one row, as the server envelopes it. */
70
+ const mineRoute = (membership: Record<string, unknown>): Route =>
71
+ get("/public/memberships/mine", () => [
72
+ { community: { id: COMMUNITY.id, name: COMMUNITY.name, slug: COMMUNITY.slug }, membership: { id: "m-1", ...membership } },
73
+ ]);
74
+
75
+ /** A community with spaces, chat and moderation, so the bar and "More" both have something. */
76
+ const busyCommunity: Route[] = [
77
+ get("/public/community/spaces", () => [{ id: "s-1", name: "General", slug: "general" }]),
78
+ get("/public/chat/channels", () => [{ id: "c-1", name: "general" }]),
79
+ get("/public/community/me", () => ({ canModerate: true })),
80
+ ];
81
+
82
+ describe("CommunityHome on a phone", () => {
83
+ it("one slim row: name, tier pill that opens the membership sheet, and Upgrade only when a higher tier exists", async () => {
84
+ installAdapter([meRoute(() => member("tier-free", "Free", {}, true)), mineRoute({ amount: null }), detailRoute(() => ({ tiers: [free, gold] }))]);
85
+ mount(
86
+ <CommunityHome tab="posts">
87
+ <p>feed</p>
88
+ </CommunityHome>,
89
+ { user: account() },
90
+ );
91
+ const compact = await screen.findByTestId("community-home-header-compact");
92
+ expect(compact.className).toContain("fg:lg:hidden");
93
+ // The desktop header is untouched, and shows from lg up only.
94
+ expect(screen.getByTestId("community-home-header").className).toContain("fg:lg:flex");
95
+ expect(within(compact).getByRole("heading", { name: "Fan Club" })).toBeTruthy();
96
+ expect(await within(compact).findByTestId("community-home-upgrade-chip")).toBeTruthy();
97
+
98
+ fireEvent.click(within(compact).getByTestId("community-home-tier-button"));
99
+ const sheet = await screen.findByTestId("community-membership-menu");
100
+ expect(within(sheet).getByTestId("community-membership-summary").textContent).toContain("Free");
101
+ });
102
+
103
+ it("no Upgrade chip when there is nowhere higher to go", async () => {
104
+ installAdapter([meRoute(() => member("tier-gold", "Gold")), mineRoute({ amount: 20, currency: "USD", billingCycle: "month" }), detailRoute(() => ({ tiers: [free, gold] }))]);
105
+ mount(
106
+ <CommunityHome tab="posts">
107
+ <p>feed</p>
108
+ </CommunityHome>,
109
+ { user: account() },
110
+ );
111
+ await screen.findByTestId("community-home-tier-button");
112
+ expect(screen.queryByTestId("community-home-upgrade-chip")).toBeNull();
113
+ });
114
+
115
+ it("the bottom bar: Feed, Spaces, Chat, Alerts and More; More lists the rest, grouped, and Your membership", async () => {
116
+ const went: string[] = [];
117
+ installAdapter([...busyCommunity, meRoute(() => member("tier-free", "Free")), mineRoute({ amount: null }), detailRoute(() => ({ tiers: [free] }))]);
118
+ mount(
119
+ <CommunityHome tab="posts" navigate={(href) => went.push(href)}>
120
+ <p>feed</p>
121
+ </CommunityHome>,
122
+ { user: account() },
123
+ );
124
+ const bar = await screen.findByTestId("community-bar");
125
+ expect(bar.className).toContain("fg:lg:hidden");
126
+ await waitFor(() =>
127
+ expect(within(bar).getAllByRole("link").map((a) => a.textContent)).toEqual(["Feed", "Spaces", "Chat", "Alerts"]),
128
+ );
129
+ expect(within(bar).getByTestId("community-bar-posts").getAttribute("aria-current")).toBe("page");
130
+ expect(within(bar).getByTestId("community-bar-more").getAttribute("aria-pressed")).toBe("false");
131
+ // The page ends above the bar.
132
+ expect(screen.getByRole("main").className).toContain("fg:pb-[calc(72px+env(safe-area-inset-bottom))]");
133
+
134
+ fireEvent.click(within(bar).getByTestId("community-bar-chat"));
135
+ expect(went).toEqual(["/i/communities/fan-club/home/chat"]);
136
+
137
+ fireEvent.click(within(bar).getByTestId("community-bar-more"));
138
+ const more = await screen.findByTestId("community-more-sheet");
139
+ expect(within(more).getByText("Community")).toBeTruthy();
140
+ expect(within(more).getByText("You")).toBeTruthy();
141
+ const rows = within(more)
142
+ .getAllByRole("button")
143
+ .map((b) => b.getAttribute("data-testid"))
144
+ .filter((id) => id?.startsWith("community-more-") && id !== "community-more-sheet");
145
+ expect(rows).toEqual([
146
+ "community-more-calendar",
147
+ "community-more-classroom",
148
+ "community-more-members",
149
+ "community-more-leaderboard",
150
+ "community-more-membership",
151
+ "community-more-moderation",
152
+ ]);
153
+
154
+ fireEvent.click(within(more).getByTestId("community-more-membership"));
155
+ expect(await screen.findByTestId("community-membership-menu")).toBeTruthy();
156
+ });
157
+
158
+ it("a page that lives under More highlights More", async () => {
159
+ installAdapter([...busyCommunity, meRoute(() => member("tier-free", "Free")), mineRoute({ amount: null }), detailRoute(() => ({ tiers: [free] }))]);
160
+ mount(
161
+ <CommunityHome tab="members">
162
+ <p>members</p>
163
+ </CommunityHome>,
164
+ { user: account() },
165
+ );
166
+ const more = await screen.findByTestId("community-bar-more");
167
+ expect(more.getAttribute("aria-pressed")).toBe("true");
168
+ expect(screen.getByTestId("community-bar-posts").getAttribute("aria-current")).toBeNull();
169
+ });
170
+
171
+ it("publishes the bar's height for the chat bubble on a phone, and clears it on the way out", async () => {
172
+ const original = window.matchMedia;
173
+ window.matchMedia = ((query: string) => ({
174
+ matches: true,
175
+ media: query,
176
+ addEventListener: () => {},
177
+ removeEventListener: () => {},
178
+ })) as unknown as typeof window.matchMedia;
179
+ try {
180
+ installAdapter([...busyCommunity, meRoute(() => member("tier-free", "Free")), mineRoute({ amount: null }), detailRoute(() => ({ tiers: [free] }))]);
181
+ const view = mount(
182
+ <CommunityHome tab="posts">
183
+ <p>feed</p>
184
+ </CommunityHome>,
185
+ { user: account() },
186
+ );
187
+ await screen.findByTestId("community-bar");
188
+ await waitFor(() => expect(document.documentElement.style.getPropertyValue("--forge-bottom-bar-height")).toBe("56px"));
189
+ view.unmount();
190
+ expect(document.documentElement.style.getPropertyValue("--forge-bottom-bar-height")).toBe("");
191
+ } finally {
192
+ window.matchMedia = original;
193
+ }
194
+ });
195
+
196
+ it("lifts the chat bubble clear of the message box in chat only, and clears it on the way out", async () => {
197
+ const original = window.matchMedia;
198
+ window.matchMedia = ((query: string) => ({
199
+ matches: true,
200
+ media: query,
201
+ addEventListener: () => {},
202
+ removeEventListener: () => {},
203
+ })) as unknown as typeof window.matchMedia;
204
+ const lift = () => document.documentElement.style.getPropertyValue("--forge-floating-lift");
205
+ try {
206
+ installAdapter([...busyCommunity, meRoute(() => member("tier-free", "Free")), mineRoute({ amount: null }), detailRoute(() => ({ tiers: [free] }))]);
207
+ const feed = mount(
208
+ <CommunityHome tab="posts">
209
+ <p>feed</p>
210
+ </CommunityHome>,
211
+ { user: account() },
212
+ );
213
+ await screen.findByTestId("community-bar");
214
+ expect(lift()).toBe("");
215
+ feed.unmount();
216
+
217
+ const chat = mount(
218
+ <CommunityHome tab="chat">
219
+ <p>chat</p>
220
+ </CommunityHome>,
221
+ { user: account() },
222
+ );
223
+ await screen.findByTestId("community-bar");
224
+ await waitFor(() => expect(lift()).toBe("88px"));
225
+ chat.unmount();
226
+ expect(lift()).toBe("");
227
+ } finally {
228
+ window.matchMedia = original;
229
+ }
230
+ });
231
+ });
232
+
233
+ describe("CommunitySheet below lg", () => {
234
+ it("is a bottom sheet with a grab handle above the chat bubble, and the centred dialog from lg up", async () => {
235
+ installAdapter([meRoute(() => member("tier-free", "Free")), mineRoute({ amount: null }), detailRoute(() => ({ tiers: [free] }))]);
236
+ mount(<CommunityMembershipMenu open onOpenChange={() => {}} />, { user: account() });
237
+ const sheet = await screen.findByTestId("community-membership-menu");
238
+ const cls = sheet.className;
239
+ for (const c of ["fg:fixed", "fg:inset-x-0", "fg:bottom-0", "fg:w-full", "fg:max-h-[90dvh]", "fg:overflow-y-auto", "fg:rounded-t-[var(--cs-radius)]"]) {
240
+ expect(cls).toContain(c);
241
+ }
242
+ for (const c of ["fg:lg:top-1/2", "fg:lg:left-1/2", "fg:lg:-translate-x-1/2", "fg:lg:-translate-y-1/2", "fg:lg:w-[min(560px,calc(100vw-32px))]"]) {
243
+ expect(cls).toContain(c);
244
+ }
245
+ expect(sheet.style.zIndex).toBe("10001");
246
+ expect(sheet.style.paddingBottom).toContain("safe-area-inset-bottom");
247
+ const handle = sheet.querySelector("[data-sheet-handle]") as HTMLElement;
248
+ expect(handle.className).toContain("fg:lg:hidden");
249
+ });
250
+ });
251
+
252
+ describe("the membership sheet", () => {
253
+ const fmt = (amount: number, currency?: string) => `${currency} ${amount}`;
254
+
255
+ it("free: says Free, offers no payment method, and the way out is Leave community, which still asks first", async () => {
256
+ installAdapter([
257
+ post("/public/communities/fan-club/membership/cancel", () => ({})),
258
+ meRoute(() => member("tier-free", "Free", {}, true)),
259
+ mineRoute({ amount: null, currency: null, billingCycle: null }),
260
+ detailRoute(() => ({ tiers: [free, gold] })),
261
+ ]);
262
+ mount(
263
+ <CommunityUpgradeOpenerProvider value={{ openUpgrade: () => {} }}>
264
+ <CommunityMembershipMenu open onOpenChange={() => {}} formatAmount={fmt} />
265
+ </CommunityUpgradeOpenerProvider>,
266
+ { user: account() },
267
+ );
268
+ const sheet = await screen.findByTestId("community-membership-menu");
269
+ await waitFor(() => expect(calls("get", "/public/memberships/mine")).toHaveLength(1));
270
+ expect(within(sheet).getByTestId("community-membership-price").textContent).toBe("Free");
271
+ expect(within(sheet).getByTestId("community-membership-upgrade")).toBeTruthy();
272
+ expect(within(sheet).queryByTestId("community-membership-row-update-card")).toBeNull();
273
+ const leave = within(sheet).getByTestId("community-membership-row-cancel");
274
+ expect(leave.textContent).toBe("Leave community");
275
+
276
+ fireEvent.click(leave);
277
+ const confirm = within(sheet).getByTestId("community-membership-cancel-confirm");
278
+ expect(confirm.textContent).toContain("Leave this community?");
279
+ expect(confirm.textContent).toContain("You lose access now.");
280
+ expect(calls("post", "/public/communities/fan-club/membership/cancel")).toHaveLength(0);
281
+ fireEvent.click(within(confirm).getByTestId("community-membership-cancel-confirm-button"));
282
+ await waitFor(() => expect(calls("post", "/public/communities/fan-club/membership/cancel")).toHaveLength(1));
283
+ });
284
+
285
+ it("paid: the price per period, the renewal date, update card, switch down, and Cancel membership with its confirmation", async () => {
286
+ installAdapter([
287
+ meRoute(() => member("tier-gold", "Gold", { currentPeriodEnd: "2026-10-27T00:00:00.000Z" })),
288
+ mineRoute({ amount: 20, currency: "USD", billingCycle: "month" }),
289
+ detailRoute(() => ({ tiers: [free, basic, gold] })),
290
+ ]);
291
+ mount(
292
+ <CommunityUpgradeOpenerProvider value={{ openUpgrade: () => {} }}>
293
+ <CommunityMembershipMenu open onOpenChange={() => {}} formatAmount={fmt} />
294
+ </CommunityUpgradeOpenerProvider>,
295
+ { user: account() },
296
+ );
297
+ const sheet = await screen.findByTestId("community-membership-menu");
298
+ await waitFor(() => expect(within(sheet).getByTestId("community-membership-price").textContent).toBe("USD 20 per month"));
299
+ expect(within(sheet).getByTestId("community-membership-period").textContent).toMatch(/^Renews on .*2026/);
300
+ // No higher tier: no Upgrade.
301
+ expect(within(sheet).queryByTestId("community-membership-upgrade")).toBeNull();
302
+ expect(within(sheet).getByTestId("community-membership-row-update-card").textContent).toBe("Update payment method");
303
+ await waitFor(() =>
304
+ expect(within(sheet).getByTestId("community-membership-row-downgrade-tier-basic").textContent).toBe("Switch to Basic at renewal"),
305
+ );
306
+ expect(within(sheet).getByTestId("community-membership-row-downgrade-tier-free")).toBeTruthy();
307
+
308
+ const cancel = within(sheet).getByTestId("community-membership-row-cancel");
309
+ expect(cancel.textContent).toBe("Cancel membership");
310
+ fireEvent.click(cancel);
311
+ const confirm = within(sheet).getByTestId("community-membership-cancel-confirm");
312
+ expect(confirm.getAttribute("role")).toBe("alertdialog");
313
+ expect(confirm.textContent).toContain("Cancel your membership?");
314
+ expect(confirm.textContent).toMatch(/You keep access until .*2026/);
315
+ // The row is replaced by the confirmation while it is open.
316
+ expect(within(sheet).queryByTestId("community-membership-row-cancel")).toBeNull();
317
+ });
318
+
319
+ it("a yearly subscription reads per year; an ending one says Ending and Ends on, with no way to cancel twice", async () => {
320
+ installAdapter([
321
+ meRoute(() => member("tier-gold", "Gold", { currentPeriodEnd: "2027-01-01T00:00:00.000Z", cancelAtPeriodEnd: true })),
322
+ mineRoute({ amount: 200, currency: "EUR", billingCycle: "year", cancelAtPeriodEnd: true }),
323
+ detailRoute(() => ({ tiers: [free, gold] })),
324
+ ]);
325
+ mount(<CommunityMembershipMenu open onOpenChange={() => {}} formatAmount={fmt} />, { user: account() });
326
+ const sheet = await screen.findByTestId("community-membership-menu");
327
+ await waitFor(() => expect(within(sheet).getByTestId("community-membership-price").textContent).toBe("EUR 200 per year"));
328
+ expect(within(sheet).getByTestId("community-membership-summary").textContent).toContain("Ending");
329
+ expect(within(sheet).getByTestId("community-membership-period").textContent).toMatch(/^Ends on /);
330
+ expect(within(sheet).queryByTestId("community-membership-row-cancel")).toBeNull();
331
+ });
332
+
333
+ it("before the price arrives, a paid tier still hides nothing it needs and a free one never shows a card row", async () => {
334
+ installAdapter([
335
+ meRoute(() => member("tier-free", "Free")),
336
+ // `/mine` never answers with a row for this membership.
337
+ get("/public/memberships/mine", () => []),
338
+ detailRoute(() => ({ tiers: [free] })),
339
+ ]);
340
+ mount(<CommunityMembershipMenu open onOpenChange={() => {}} />, { user: account() });
341
+ const sheet = await screen.findByTestId("community-membership-menu");
342
+ await waitFor(() => expect(within(sheet).getByTestId("community-membership-price").textContent).toBe("Free"));
343
+ expect(within(sheet).queryByTestId("community-membership-row-update-card")).toBeNull();
344
+ });
345
+ });
346
+
347
+ describe("the join sheet, email first", () => {
348
+ it("asks for an email only, with no Create account / Sign in pair, and a password link", async () => {
349
+ installAdapter([
350
+ post("/public/sessions/email/start", (req) => ({ status: "code_sent", email: req.body?.email })),
351
+ detailRoute(() => ({ tiers: [free], viewer: viewer({ state: "anonymous" }) })),
352
+ ]);
353
+ mount(<CommunityJoinSheet open onOpenChange={() => {}} />);
354
+ const sheet = await screen.findByTestId("community-join-sheet");
355
+ expect(within(sheet).getByRole("heading", { name: "Join Fan Club" })).toBeTruthy();
356
+ const form = await within(sheet).findByTestId("community-join-email");
357
+ expect(within(sheet).queryByRole("tab")).toBeNull();
358
+ expect(form.querySelector('input[type="password"]')).toBeNull();
359
+ expect(within(form).getByTestId("community-join-use-password").textContent).toBe("Use your password instead");
360
+
361
+ fireEvent.change(within(form).getByLabelText("Email"), { target: { value: "fan@example.com" } });
362
+ fireEvent.submit(form);
363
+ await waitFor(() => expect(within(sheet).getByText("We emailed a 6-digit code to fan@example.com.")).toBeTruthy());
364
+ expect(within(sheet).getByTestId("community-join-change-email").textContent).toBe("Use a different email");
365
+ expect(within(sheet).getByTestId("community-join-resend").textContent).toBe("Resend code");
366
+ });
367
+
368
+ it("the new-person step asks for a name and the terms, and only then", async () => {
369
+ installAdapter([
370
+ post("/public/sessions/email/start", (req) => ({ status: "code_sent", email: req.body?.email })),
371
+ post("/public/sessions/email/verify", () => ({ status: "needs_profile", ticket: "ab".repeat(32) })),
372
+ detailRoute(() => ({ tiers: [free], viewer: viewer({ state: "anonymous" }) })),
373
+ ]);
374
+ mount(<CommunityJoinSheet open onOpenChange={() => {}} />);
375
+ const sheet = await screen.findByTestId("community-join-sheet");
376
+ const form = await within(sheet).findByTestId("community-join-email");
377
+ fireEvent.change(within(form).getByLabelText("Email"), { target: { value: "new@example.com" } });
378
+ fireEvent.submit(form);
379
+ const code = await within(sheet).findByLabelText("6-digit code");
380
+ fireEvent.change(code, { target: { value: "123456" } });
381
+ fireEvent.submit(code.closest("form")!);
382
+ const profile = await within(sheet).findByTestId("community-join-profile");
383
+ expect(within(profile).getByLabelText("First name")).toBeTruthy();
384
+ expect(within(profile).getByLabelText("Last name")).toBeTruthy();
385
+ expect(within(profile).getByLabelText("I accept the Terms and Privacy Policy")).toBeTruthy();
386
+ expect(within(profile).getByTestId("community-join-start-again")).toBeTruthy();
387
+ });
388
+
389
+ it("Use your password instead shows the email and password sign-in, and a way back", async () => {
390
+ installAdapter([detailRoute(() => ({ tiers: [free], viewer: viewer({ state: "anonymous" }) }))]);
391
+ mount(<CommunityJoinSheet open onOpenChange={() => {}} />);
392
+ const sheet = await screen.findByTestId("community-join-sheet");
393
+ fireEvent.click(await within(sheet).findByTestId("community-join-use-password"));
394
+ expect(within(sheet).getByLabelText("Password")).toBeTruthy();
395
+ expect(within(sheet).queryByLabelText("First name")).toBeNull();
396
+ fireEvent.click(within(sheet).getByTestId("community-join-use-code"));
397
+ expect(await within(sheet).findByTestId("community-join-email")).toBeTruthy();
398
+ });
399
+ });
@@ -0,0 +1,102 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
4
+ import { ForgeThemeProvider } from "../../../theme/ForgeThemeProvider";
5
+ import { CommunityAbout } from "../CommunityAbout";
6
+ import { CommunityHome } from "../CommunityHome";
7
+ import { CommunitiesList } from "../CommunitiesList";
8
+ import {
9
+ COMMUNITY,
10
+ account,
11
+ detailRoute,
12
+ get,
13
+ installAdapter,
14
+ meRoute,
15
+ restoreAdapter,
16
+ tier,
17
+ viewer,
18
+ wrapper,
19
+ } from "../../../headless/_tests/communityHarness";
20
+
21
+ /**
22
+ * The styled community pages are their headless hooks plus markup. These pin
23
+ * the markup's side of the contract: the About page's one button and the
24
+ * in-place sheet, the home header (tier badge, Upgrade, Membership), and the
25
+ * list linking to About pages.
26
+ */
27
+
28
+ afterEach(restoreAdapter);
29
+
30
+ const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
31
+
32
+ function mount(node: React.ReactNode, opts: Parameters<typeof wrapper>[0] = {}) {
33
+ const Wrapper = wrapper(opts);
34
+ return render(
35
+ <Wrapper>
36
+ <ForgeThemeProvider theme={theme}>{node}</ForgeThemeProvider>
37
+ </Wrapper>,
38
+ );
39
+ }
40
+
41
+ const free = tier({ id: "tier-free", name: "Free", benefits: [{ id: "b1", title: "The feed" }] as never });
42
+
43
+ describe("CommunityAbout", () => {
44
+ it("shows the community, its tiers, and Join free that opens the sheet in place", async () => {
45
+ installAdapter([detailRoute(() => ({ tiers: [free], viewer: viewer({ state: "anonymous" }) }))]);
46
+ mount(<CommunityAbout />);
47
+ expect(await screen.findByRole("heading", { name: "Fan Club" })).toBeTruthy();
48
+ expect((await screen.findAllByTestId("community-about-tier"))[0].textContent).toContain("The feed");
49
+ const join = await screen.findByTestId("community-about-primary");
50
+ await waitFor(() => expect(join.textContent).toBe("Join free"));
51
+ fireEvent.click(join);
52
+ expect(await screen.findByTestId("community-join-sheet")).toBeTruthy();
53
+ expect(window.location.pathname).not.toContain("/i/account");
54
+ });
55
+
56
+ it("opens the sheet straight away on an invite", async () => {
57
+ installAdapter([
58
+ get("/public/communities/fan-club/invite", () => ({ kind: "email", state: "expired", invitedEmail: null, tier: null })),
59
+ detailRoute(() => ({ tiers: [free] })),
60
+ ]);
61
+ mount(<CommunityAbout invite="tok" />);
62
+ const sheet = await screen.findByTestId("community-join-sheet");
63
+ expect((await within(sheet).findByTestId("community-join-invite-problem")).textContent).toContain("expired");
64
+ });
65
+ });
66
+
67
+ describe("CommunityHome", () => {
68
+ it("renders the header with the tier badge, Upgrade and Membership, then the body", async () => {
69
+ installAdapter([
70
+ meRoute(() =>
71
+ viewer({
72
+ state: "member",
73
+ membership: { id: "m-1", tierId: "tier-free", tierName: "Free", status: "active", cancelAtPeriodEnd: false, currentPeriodEnd: null, pendingDowngradeTierId: null },
74
+ upgradeTiers: [{ id: "tier-gold", name: "Gold", priceMonthly: 20, priceYearly: null }],
75
+ }),
76
+ ),
77
+ detailRoute(() => ({ tiers: [free] })),
78
+ ]);
79
+ mount(
80
+ <CommunityHome tab="posts">
81
+ <p data-testid="body">feed</p>
82
+ </CommunityHome>,
83
+ { user: account() },
84
+ );
85
+ expect((await screen.findByTestId("community-home-tier-badge")).textContent).toBe("Free");
86
+ expect(await screen.findByTestId("community-home-upgrade")).toBeTruthy();
87
+ expect(screen.getByTestId("community-home-membership")).toBeTruthy();
88
+ expect(screen.getByTestId("body").textContent).toBe("feed");
89
+ fireEvent.click(screen.getByTestId("community-home-upgrade"));
90
+ expect(await screen.findByTestId("community-upgrade-sheet")).toBeTruthy();
91
+ });
92
+ });
93
+
94
+ describe("CommunitiesList", () => {
95
+ it("links each community to its About page", async () => {
96
+ installAdapter([get("/public/communities", () => [COMMUNITY])]);
97
+ const went: string[] = [];
98
+ mount(<CommunitiesList navigate={(href) => went.push(href)} />, { community: null });
99
+ fireEvent.click(await screen.findByTestId("community-card"));
100
+ expect(went).toEqual(["/i/communities/fan-club"]);
101
+ });
102
+ });