@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,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { APP_BAR_HREFS, buildAppBar, isAppBarItemActive, normaliseBarItems } from "../siteApp/appBar";
3
+ import { SITE_APP_BAR_KEYS } from "../../../data/queries/useSiteApp";
4
+
5
+ /**
6
+ * The site app's bottom bar model: Home first, the creator's items (at most
7
+ * three) after it, More last, and every other destination behind More.
8
+ */
9
+
10
+ const keys = (items: Array<{ key: string }>) => items.map((item) => item.key);
11
+
12
+ describe("buildAppBar", () => {
13
+ it("puts Home first, then the configured items in their order, and the rest behind More", () => {
14
+ const model = buildAppBar({ barItems: ["shop", "events"], homeHref: "/i/app" });
15
+ expect(keys(model.bar)).toEqual(["home", "shop", "events"]);
16
+ // The sheet keeps the canonical order, not the configured one.
17
+ expect(keys(model.more)).toEqual(["communities", "portal", "inbox", "courses"]);
18
+ });
19
+
20
+ it("uses the contract's default bar when nothing is configured", () => {
21
+ for (const barItems of [undefined, null, []]) {
22
+ const model = buildAppBar({ barItems, homeHref: "/i/app" });
23
+ expect(keys(model.bar)).toEqual(["home", "communities", "portal", "inbox"]);
24
+ expect(keys(model.more)).toEqual(["events", "courses", "shop"]);
25
+ }
26
+ });
27
+
28
+ it("drops unknown keys and repeats and holds at most three items", () => {
29
+ const model = buildAppBar({
30
+ barItems: ["inbox", "nope", "inbox", "courses", "home", "shop", "events"],
31
+ homeHref: "/i/app",
32
+ });
33
+ expect(keys(model.bar)).toEqual(["home", "inbox", "courses", "shop"]);
34
+ expect(normaliseBarItems(["events", "events", "bogus"])).toEqual(["events"]);
35
+ });
36
+
37
+ it("lists every destination exactly once, in the bar or in More", () => {
38
+ for (const barItems of [["communities"], ["portal", "inbox", "events"], ["shop", "courses", "communities"]]) {
39
+ const model = buildAppBar({ barItems, homeHref: "/i/app" });
40
+ const all = [...keys(model.bar), ...keys(model.more)];
41
+ expect(all.sort()).toEqual(["home", ...SITE_APP_BAR_KEYS].sort());
42
+ }
43
+ });
44
+
45
+ it("links each destination to its page, and Home to the Home it is given", () => {
46
+ const model = buildAppBar({ barItems: ["inbox"], homeHref: "/members" });
47
+ expect(model.bar[0]).toMatchObject({ key: "home", href: "/members" });
48
+ expect(model.bar[1]).toMatchObject({ key: "inbox", href: "/i/account/notifications" });
49
+ expect(APP_BAR_HREFS).toEqual({
50
+ communities: "/i/communities",
51
+ portal: "/i/account",
52
+ inbox: "/i/account/notifications",
53
+ events: "/i/events",
54
+ courses: "/i/courses",
55
+ shop: "/i/store",
56
+ });
57
+ });
58
+
59
+ it("highlights the page being shown, and More when that page lives in the sheet", () => {
60
+ const onEvents = buildAppBar({ barItems: ["events"], homeHref: "/i/app", currentPath: "/i/events/summit" });
61
+ expect(onEvents.bar.find((item) => item.key === "events")?.active).toBe(true);
62
+ expect(onEvents.moreActive).toBe(false);
63
+
64
+ const inSheet = buildAppBar({ barItems: ["communities"], homeHref: "/i/app", currentPath: "/i/store/tee?x=1" });
65
+ expect(inSheet.more.find((item) => item.key === "shop")?.active).toBe(true);
66
+ expect(inSheet.moreActive).toBe(true);
67
+
68
+ const home = buildAppBar({ barItems: [], homeHref: "/i/app", currentPath: "/i/app/" });
69
+ expect(home.bar[0]!.active).toBe(true);
70
+ });
71
+
72
+ it("does not let the portal claim the inbox, which lives under it", () => {
73
+ expect(isAppBarItemActive("portal", "/i/account", "/i/account/notifications")).toBe(false);
74
+ expect(isAppBarItemActive("inbox", "/i/account/notifications", "/i/account/notifications")).toBe(true);
75
+ expect(isAppBarItemActive("portal", "/i/account", "/i/account/invoices")).toBe(true);
76
+ });
77
+
78
+ it("matches Home exactly, so a custom Home at the root does not light up on every page", () => {
79
+ expect(isAppBarItemActive("home", "/", "/i/events")).toBe(false);
80
+ expect(isAppBarItemActive("home", "/", "/")).toBe(true);
81
+ expect(isAppBarItemActive("events", "/i/events", "/i/eventsx")).toBe(false);
82
+ });
83
+ });
@@ -0,0 +1,19 @@
1
+ import type { SiteApp } from "../../../data/queries/useSiteApp";
2
+
3
+ /** The public site app settings (`GET /public/websites/app`), as the contract shapes them. */
4
+ export const siteApp = (patch: Partial<SiteApp> = {}): SiteApp => ({
5
+ websiteId: "website-1",
6
+ profileId: "profile-1",
7
+ appName: "Northlight",
8
+ shortName: "Northlight",
9
+ iconUrl: null,
10
+ themeColor: null,
11
+ backgroundColor: null,
12
+ startUrl: "/i/app",
13
+ homeSections: [{ type: "builtin", key: "portal", visible: true }],
14
+ barItems: ["communities", "portal", "inbox"],
15
+ installPrompt: true,
16
+ pushPrompt: true,
17
+ activeVersionId: null,
18
+ ...patch,
19
+ });
@@ -0,0 +1,226 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildAppHomeSections,
4
+ buildPortalTiles,
5
+ courseProgress,
6
+ pickLearningCourse,
7
+ pickNextCall,
8
+ pickNextTicket,
9
+ upcomingEvents,
10
+ type AppHomeData,
11
+ } from "../siteApp/appHome";
12
+ import type { SiteAppHomeSection } from "../../../data/queries/useSiteApp";
13
+ import type { PortalSummary } from "../portal/usePortal";
14
+ import type { MyBooking } from "../../../data/queries/useMyBookings";
15
+ import type { MyTicket } from "../../../data/queries/useMyTickets";
16
+ import type { MyMembershipCall } from "../../../data/queries/useMembershipCalls";
17
+ import type { CourseAccessData, IEvent } from "../../../types/models";
18
+
19
+ /**
20
+ * The app home's sections: the configured order, hidden entries skipped,
21
+ * empty sections skipped, and the portal drawn whatever it holds.
22
+ */
23
+
24
+ const builtin = (key: string, visible = true) => ({ type: "builtin", key, visible }) as SiteAppHomeSection;
25
+ const link = (id: string, label: string, href: string, visible = true) =>
26
+ ({ type: "link", id, label, href, visible }) as SiteAppHomeSection;
27
+
28
+ const community = (id: string) => ({
29
+ id,
30
+ name: `Community ${id}`,
31
+ slug: `c-${id}`,
32
+ logoUrl: null,
33
+ coverUrl: null,
34
+ tierName: null,
35
+ memberCount: 3,
36
+ href: `/i/communities/c-${id}`,
37
+ });
38
+
39
+ const full: AppHomeData = {
40
+ attention: [
41
+ {
42
+ kind: "invoice_due",
43
+ id: "inv-1",
44
+ invoiceNumber: "1042",
45
+ total: "450",
46
+ currency: "USD",
47
+ dueDate: null,
48
+ projectId: null,
49
+ projectName: null,
50
+ },
51
+ ],
52
+ communities: [community("a")],
53
+ nextCall: { id: "call:1", title: "Group call", startsAt: "2026-10-02T18:00:00Z", joinable: false, href: "/x", context: null },
54
+ nextTicket: null,
55
+ learning: { accessId: "acc-1", title: "Pricing", href: "/i/course-access/acc-1", progress: null },
56
+ explore: [community("b"), community("c"), community("d"), community("e")],
57
+ events: [{ id: "e-1" } as IEvent],
58
+ courses: [],
59
+ products: [],
60
+ };
61
+
62
+ const kinds = (config: SiteAppHomeSection[], data: AppHomeData) => buildAppHomeSections(config, data).map((s) => s.kind);
63
+
64
+ describe("buildAppHomeSections", () => {
65
+ it("draws the sections in the configured order", () => {
66
+ const config = [builtin("learning"), builtin("portal"), builtin("needs_you"), builtin("communities"), builtin("coming_up")];
67
+ expect(kinds(config, full)).toEqual(["learning", "portal", "needs_you", "communities", "coming_up"]);
68
+ });
69
+
70
+ it("skips hidden sections", () => {
71
+ const config = [builtin("needs_you", false), builtin("portal"), builtin("events", false), builtin("communities")];
72
+ expect(kinds(config, full)).toEqual(["portal", "communities"]);
73
+ });
74
+
75
+ it("skips a section with nothing to show, except the portal", () => {
76
+ const config = [
77
+ builtin("needs_you"),
78
+ builtin("portal"),
79
+ builtin("communities"),
80
+ builtin("coming_up"),
81
+ builtin("learning"),
82
+ builtin("explore_communities"),
83
+ builtin("events"),
84
+ builtin("courses"),
85
+ builtin("shop"),
86
+ ];
87
+ expect(kinds(config, {})).toEqual(["portal"]);
88
+ // The portal with no summary still has its Settings tile.
89
+ const [portal] = buildAppHomeSections([builtin("portal")], {});
90
+ expect(portal).toMatchObject({ kind: "portal", tiles: [{ key: "settings" }] });
91
+ // Courses and shop configured but empty are left out while the rest draw.
92
+ expect(kinds(config, full)).toEqual([
93
+ "needs_you",
94
+ "portal",
95
+ "communities",
96
+ "coming_up",
97
+ "learning",
98
+ "explore_communities",
99
+ "events",
100
+ ]);
101
+ });
102
+
103
+ it("draws link sections where they are placed, and only with a safe address", () => {
104
+ const config = [
105
+ link("l1", "Book a call", "/book"),
106
+ builtin("portal"),
107
+ link("l2", "Our podcast", "https://example.com/pod"),
108
+ link("l3", "Bad", "javascript:alert(1)"),
109
+ link("l4", "Off-site trick", "//evil.example"),
110
+ link("l5", " ", "/blank"),
111
+ link("l6", "Hidden", "/hidden", false),
112
+ ];
113
+ const sections = buildAppHomeSections(config, {});
114
+ expect(sections.map((s) => s.key)).toEqual(["l1", "portal", "l2"]);
115
+ expect(sections[0]).toEqual({ kind: "link", key: "l1", label: "Book a call", href: "/book" });
116
+ });
117
+
118
+ it("ignores unknown keys and repeats", () => {
119
+ const config = [builtin("portal"), builtin("mystery"), builtin("portal"), link("x", "A", "/a"), link("x", "B", "/b")];
120
+ expect(buildAppHomeSections(config, {}).map((s) => s.key)).toEqual(["portal", "x"]);
121
+ });
122
+
123
+ it("caps the explore list and turns attention into one-tap actions", () => {
124
+ const sections = buildAppHomeSections([builtin("needs_you"), builtin("explore_communities")], full);
125
+ expect(sections[0]).toMatchObject({ kind: "needs_you", actions: [{ action: "pay", href: "/i/invoice/inv-1" }] });
126
+ expect(sections[1]!.kind === "explore_communities" && sections[1]!.communities.length).toBe(3);
127
+ });
128
+
129
+ it("draws nothing until the settings arrive", () => {
130
+ expect(buildAppHomeSections(undefined, full)).toEqual([]);
131
+ });
132
+ });
133
+
134
+ describe("buildPortalTiles", () => {
135
+ const summary = (sections: Partial<PortalSummary["sections"]>, attention: PortalSummary["attention"] = []): PortalSummary =>
136
+ ({ sections, attention, lastActivity: { work: null, library: null } }) as PortalSummary;
137
+
138
+ it("draws a tile per section the person has anything in, with what needs them as the badge", () => {
139
+ const tiles = buildPortalTiles(
140
+ summary({ projects: 1, invoices: 2, documents: 1, orders: 0 }, [
141
+ { kind: "invoice_overdue", id: "i", invoiceNumber: "1", total: "1", currency: "USD", dueDate: null, projectId: null, projectName: null },
142
+ { kind: "document_to_sign", id: "d", title: "Agreement", token: "t", sentAt: "2026-01-01", expiresAt: null },
143
+ ]),
144
+ );
145
+ expect(tiles).toEqual([
146
+ { key: "projects", href: "/i/account/projects", attention: 0 },
147
+ { key: "invoices", href: "/i/account/invoices", attention: 1 },
148
+ { key: "documents", href: "/i/account/documents", attention: 1 },
149
+ { key: "settings", href: "/i/account/settings", attention: 0 },
150
+ ]);
151
+ });
152
+
153
+ it("is exactly the eight sections when the person has all of them", () => {
154
+ const tiles = buildPortalTiles(
155
+ summary({ projects: 1, bookings: 1, invoices: 1, documents: 1, courses: 1, orders: 1, tickets: 1, saved: 1 }),
156
+ );
157
+ expect(tiles.map((t) => t.key)).toEqual(["projects", "bookings", "invoices", "documents", "courses", "orders", "tickets", "saved"]);
158
+ });
159
+ });
160
+
161
+ describe("the section pickers", () => {
162
+ const now = new Date("2026-10-01T12:00:00Z");
163
+
164
+ it("picks the soonest call that has not ended, joinable when its room is open", () => {
165
+ const calls = [
166
+ { id: "c1", title: "Later call", startsAt: "2026-10-03T10:00:00Z", endsAt: "2026-10-03T11:00:00Z", isOpen: false, callPublicId: "room-1", audiences: [{ label: "Gold" }] },
167
+ { id: "c0", title: "Ended", startsAt: "2026-09-30T10:00:00Z", endsAt: "2026-09-30T11:00:00Z", isOpen: false, callPublicId: "room-0", audiences: [] },
168
+ ] as unknown as MyMembershipCall[];
169
+ const bookings = [
170
+ { id: "b1", status: "confirmed", sessionStartTime: "2026-10-01T12:05:00Z", sessionEndTime: "2026-10-01T13:00:00Z", videoCallId: "vc-1", coachingProductTitle: "1:1 session", location: { type: "video" } },
171
+ { id: "b2", status: "canceled", sessionStartTime: "2026-10-01T12:01:00Z", sessionEndTime: "2026-10-01T13:00:00Z", videoCallId: "vc-2", coachingProductTitle: "Cancelled" },
172
+ ] as unknown as MyBooking[];
173
+ expect(pickNextCall(calls, bookings, now)).toEqual({
174
+ id: "booking:b1",
175
+ title: "1:1 session",
176
+ startsAt: "2026-10-01T12:05:00Z",
177
+ joinable: true,
178
+ href: "/i/video-calls/vc-1",
179
+ context: null,
180
+ });
181
+ expect(pickNextCall(calls, [], now)).toMatchObject({ id: "call:c1", joinable: false, context: "Gold", href: "/i/video-calls/room-1" });
182
+ expect(pickNextCall([], [], now)).toBeNull();
183
+ });
184
+
185
+ it("picks the next ticket, counting its passes and skipping cancelled orders", () => {
186
+ const tickets = [
187
+ { id: "t2", status: "paid", selfCancelledAt: null, eventTitle: "Summit", eventDateTime: "2026-11-14T09:00:00Z", eventEndDateTime: null, items: [{ quantity: 2 }] },
188
+ { id: "t1", status: "refunded", selfCancelledAt: null, eventTitle: "Refunded", eventDateTime: "2026-10-05T09:00:00Z", eventEndDateTime: null, items: [] },
189
+ ] as unknown as MyTicket[];
190
+ expect(pickNextTicket(tickets, now)).toEqual({
191
+ id: "t2",
192
+ title: "Summit",
193
+ startsAt: "2026-11-14T09:00:00Z",
194
+ count: 2,
195
+ href: "/i/account/tickets",
196
+ });
197
+ });
198
+
199
+ it("carries on with a started course, and measures it in lessons", () => {
200
+ const course = (accessId: string, patch: object) => ({ accessId, courseId: accessId, title: accessId, completedAt: null, currentLessonId: null, ...patch });
201
+ expect(pickLearningCourse([course("done", { completedAt: "2026-01-01" }), course("new", {}), course("started", { currentLessonId: "l2" })] as never)?.accessId).toBe("started");
202
+ expect(pickLearningCourse([course("done", { completedAt: "2026-01-01" })] as never)).toBeNull();
203
+
204
+ const access = {
205
+ access: {},
206
+ course: { modules: [{ lessons: [{ id: "l1" }, { id: "l2" }] }, { lessons: [{ id: "l3" }, { id: "l4" }] }] },
207
+ progress: [
208
+ { lessonId: "l1", isCompleted: true },
209
+ { lessonId: "l2", isCompleted: false },
210
+ { lessonId: "l3", isCompleted: true },
211
+ ],
212
+ } as unknown as CourseAccessData;
213
+ expect(courseProgress(access)).toEqual({ done: 2, total: 4, ratio: 0.5 });
214
+ expect(courseProgress(null)).toBeNull();
215
+ });
216
+
217
+ it("lists only events still to come, soonest first", () => {
218
+ const events = [
219
+ { id: "late", status: "on_sale", dateTime: "2026-12-01T00:00:00Z", endDateTime: null, cancelledAt: null },
220
+ { id: "past", status: "on_sale", dateTime: "2026-09-01T00:00:00Z", endDateTime: null, cancelledAt: null },
221
+ { id: "soon", status: "scheduled", dateTime: "2026-10-10T00:00:00Z", endDateTime: null, cancelledAt: null },
222
+ { id: "off", status: "cancelled", dateTime: "2026-10-11T00:00:00Z", endDateTime: null, cancelledAt: "2026-09-01" },
223
+ ] as unknown as IEvent[];
224
+ expect(upcomingEvents(events, now).map((e) => e.id)).toEqual(["soon", "late"]);
225
+ });
226
+ });
@@ -0,0 +1,185 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ APP_INSTALL_REQUEST_TTL_MS,
5
+ APP_INSTALL_SNOOZE_MS,
6
+ clearPendingAppInstall,
7
+ decideAppInstall,
8
+ decideAppPush,
9
+ pushPreferencePatch,
10
+ readInstallDismissedAt,
11
+ readPendingAppInstall,
12
+ readPushAnswer,
13
+ rememberInstallDismissed,
14
+ rememberPushAnswer,
15
+ requestAppInstall,
16
+ subscribeAppInstallRequests,
17
+ } from "../siteApp/appPrompts";
18
+
19
+ /**
20
+ * The gating of the site app's two prompts, and the storage around them.
21
+ */
22
+
23
+ const DAY = 24 * 60 * 60 * 1000;
24
+ const NOW = Date.parse("2026-10-01T12:00:00Z");
25
+
26
+ const install = (patch: Partial<Parameters<typeof decideAppInstall>[0]> = {}) =>
27
+ decideAppInstall({
28
+ installPrompt: true,
29
+ isStandalone: false,
30
+ isIOS: false,
31
+ hasNativePrompt: true,
32
+ dismissedAt: null,
33
+ now: NOW,
34
+ ...patch,
35
+ });
36
+
37
+ beforeEach(() => {
38
+ window.localStorage.clear();
39
+ window.sessionStorage.clear();
40
+ clearPendingAppInstall();
41
+ });
42
+
43
+ afterEach(() => {
44
+ vi.restoreAllMocks();
45
+ });
46
+
47
+ describe("decideAppInstall", () => {
48
+ it("uses the native prompt where the browser holds one", () => {
49
+ expect(install()).toBe("native");
50
+ });
51
+
52
+ it("gives an iPhone the two Safari steps, which has no native prompt", () => {
53
+ expect(install({ hasNativePrompt: false, isIOS: true })).toBe("ios");
54
+ });
55
+
56
+ it("never shows in the installed app", () => {
57
+ expect(install({ isStandalone: true })).toBeNull();
58
+ expect(install({ isStandalone: true, isIOS: true, hasNativePrompt: false })).toBeNull();
59
+ });
60
+
61
+ it("respects the website's setting, and treats an unknown setting as off", () => {
62
+ expect(install({ installPrompt: false })).toBeNull();
63
+ expect(install({ installPrompt: undefined })).toBeNull();
64
+ expect(install({ installPrompt: null })).toBeNull();
65
+ });
66
+
67
+ it("stays away for 14 days after Not now, and comes back after", () => {
68
+ expect(install({ dismissedAt: NOW - 1 * DAY })).toBeNull();
69
+ expect(install({ dismissedAt: NOW - 13 * DAY })).toBeNull();
70
+ expect(install({ dismissedAt: NOW - APP_INSTALL_SNOOZE_MS })).toBe("native");
71
+ expect(install({ dismissedAt: NOW - 15 * DAY })).toBe("native");
72
+ });
73
+
74
+ it("has nothing to show where there is neither a prompt nor Safari's steps", () => {
75
+ expect(install({ hasNativePrompt: false, isIOS: false })).toBeNull();
76
+ });
77
+ });
78
+
79
+ describe("Not now, remembered", () => {
80
+ it("round-trips through localStorage", () => {
81
+ expect(readInstallDismissedAt()).toBeNull();
82
+ rememberInstallDismissed(NOW);
83
+ expect(readInstallDismissedAt()).toBe(NOW);
84
+ });
85
+
86
+ it("never throws when storage is blocked", () => {
87
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
88
+ throw new Error("SecurityError");
89
+ });
90
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
91
+ throw new Error("SecurityError");
92
+ });
93
+ expect(() => rememberInstallDismissed(NOW)).not.toThrow();
94
+ expect(readInstallDismissedAt()).toBeNull();
95
+ expect(() => rememberPushAnswer("accepted")).not.toThrow();
96
+ expect(readPushAnswer()).toBeNull();
97
+ });
98
+ });
99
+
100
+ describe("requestAppInstall", () => {
101
+ it("holds the request for the page that can show it, and tells listeners", () => {
102
+ const heard = vi.fn();
103
+ const off = subscribeAppInstallRequests(heard);
104
+ requestAppInstall("purchase");
105
+ expect(heard).toHaveBeenCalledTimes(1);
106
+ expect(readPendingAppInstall()).toMatchObject({ context: "purchase" });
107
+ off();
108
+ });
109
+
110
+ it("survives a navigation through sessionStorage", () => {
111
+ requestAppInstall("joined_community");
112
+ const stored = window.sessionStorage.getItem("forge-app-install-pending");
113
+ clearPendingAppInstall();
114
+ expect(readPendingAppInstall()).toBeNull();
115
+ // A fresh page: the copy in memory is gone, the session copy is not.
116
+ window.sessionStorage.setItem("forge-app-install-pending", stored!);
117
+ expect(readPendingAppInstall()).toMatchObject({ context: "joined_community" });
118
+ });
119
+
120
+ it("lets a stale request lapse", () => {
121
+ requestAppInstall("booking");
122
+ const at = readPendingAppInstall()!.at;
123
+ expect(readPendingAppInstall(at + APP_INSTALL_REQUEST_TTL_MS + 1)).toBeNull();
124
+ });
125
+
126
+ it("ignores a context it does not know", () => {
127
+ requestAppInstall("nonsense" as never);
128
+ expect(readPendingAppInstall()).toBeNull();
129
+ });
130
+ });
131
+
132
+ describe("decideAppPush", () => {
133
+ const push = (patch: Partial<Parameters<typeof decideAppPush>[0]> = {}) =>
134
+ decideAppPush({
135
+ pushPrompt: true,
136
+ isStandalone: true,
137
+ isSignedIn: true,
138
+ isSupported: true,
139
+ permission: "default",
140
+ answer: null,
141
+ ...patch,
142
+ });
143
+
144
+ it("offers on the first launch of the installed app", () => {
145
+ expect(push()).toBe(true);
146
+ });
147
+
148
+ it("does not offer in a browser tab", () => {
149
+ expect(push({ isStandalone: false })).toBe(false);
150
+ });
151
+
152
+ it("respects the website's setting", () => {
153
+ expect(push({ pushPrompt: false })).toBe(false);
154
+ expect(push({ pushPrompt: undefined })).toBe(false);
155
+ });
156
+
157
+ it("needs someone signed in, and a browser that can push", () => {
158
+ expect(push({ isSignedIn: false })).toBe(false);
159
+ expect(push({ isSupported: false })).toBe(false);
160
+ });
161
+
162
+ it("asks only while the browser has not been asked", () => {
163
+ expect(push({ permission: "granted" })).toBe(false);
164
+ expect(push({ permission: "denied" })).toBe(false);
165
+ });
166
+
167
+ it("asks once: any earlier answer ends it", () => {
168
+ expect(push({ answer: "declined" })).toBe(false);
169
+ expect(push({ answer: "accepted" })).toBe(false);
170
+ rememberPushAnswer("declined");
171
+ expect(push({ answer: readPushAnswer() })).toBe(false);
172
+ });
173
+ });
174
+
175
+ describe("pushPreferencePatch", () => {
176
+ it("switches every known type of each category, and leaves unknown types alone", () => {
177
+ const known = new Set(["dm", "mention", "new_post", "event_reminder"]);
178
+ expect(pushPreferencePatch({ messages: true, community: false, events: true }, known)).toEqual({
179
+ "dm.push": true,
180
+ "mention.push": true,
181
+ "new_post.push": false,
182
+ "event_reminder.push": true,
183
+ });
184
+ });
185
+ });
@@ -0,0 +1,104 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { renderHook, waitFor } from "@testing-library/react";
4
+ import { describeCommunityInvite, useCommunityInvite } from "../communities/useCommunityInvite";
5
+ import type { CommunityInviteState } from "../../../data/queries/useCommunities";
6
+ import { account, calls, get, installAdapter, restoreAdapter, tier, wrapper } from "./communityHarness";
7
+
8
+ /**
9
+ * What an invite is worth to THIS viewer: every state the server can answer,
10
+ * the mismatch only the client can see (signed in on this page as someone
11
+ * else), and which token is forwarded (only a usable one).
12
+ */
13
+
14
+ afterEach(restoreAdapter);
15
+
16
+ const vip = tier({ id: "tier-vip", joinMode: "invite" });
17
+ const answer = (patch: Partial<CommunityInviteState>): CommunityInviteState => ({
18
+ kind: "email",
19
+ state: "valid",
20
+ invitedEmail: "invited@example.com",
21
+ tier: vip,
22
+ ...patch,
23
+ });
24
+
25
+ describe("describeCommunityInvite", () => {
26
+ it("no token: nothing to say and nothing to forward", () => {
27
+ expect(describeCommunityInvite({ tokens: {}, answer: undefined })).toMatchObject({
28
+ kind: null,
29
+ state: null,
30
+ isValid: false,
31
+ problem: null,
32
+ forward: {},
33
+ });
34
+ });
35
+
36
+ it("a valid per-email invite forwards the invite token and names its tier and email", () => {
37
+ expect(describeCommunityInvite({ tokens: { invite: "tok" }, answer: answer({}), signedInEmail: null })).toMatchObject({
38
+ kind: "email",
39
+ state: "valid",
40
+ isValid: true,
41
+ emailMismatch: false,
42
+ problem: null,
43
+ invitedEmail: "invited@example.com",
44
+ tier: vip,
45
+ forward: { inviteToken: "tok" },
46
+ });
47
+ });
48
+
49
+ it("signed in as another address (case-insensitively) is a mismatch, still forwarded so signing in as the invitee works", () => {
50
+ const summary = describeCommunityInvite({ tokens: { invite: "tok" }, answer: answer({}), signedInEmail: "Other@Example.com" });
51
+ expect(summary).toMatchObject({ state: "email_mismatch", isValid: false, emailMismatch: true, forward: { inviteToken: "tok" } });
52
+ expect(describeCommunityInvite({ tokens: { invite: "tok" }, answer: answer({}), signedInEmail: "INVITED@example.com" }).isValid).toBe(true);
53
+ });
54
+
55
+ it("the server's own email_mismatch is kept", () => {
56
+ expect(describeCommunityInvite({ tokens: { invite: "tok" }, answer: answer({ state: "email_mismatch" }) }).emailMismatch).toBe(true);
57
+ });
58
+
59
+ it.each(["expired", "revoked", "used", "used_up", "not_found"] as const)("%s is a problem, with no tier and no token forwarded", (state) => {
60
+ expect(describeCommunityInvite({ tokens: { invite: "tok" }, answer: answer({ state, tier: vip }) })).toMatchObject({
61
+ state,
62
+ isValid: false,
63
+ problem: state,
64
+ tier: null,
65
+ forward: {},
66
+ });
67
+ });
68
+
69
+ it("a shareable link forwards the join token and never mismatches on email", () => {
70
+ const summary = describeCommunityInvite({
71
+ tokens: { join: "link" },
72
+ answer: answer({ kind: "link", invitedEmail: null }),
73
+ signedInEmail: "anyone@example.com",
74
+ });
75
+ expect(summary).toMatchObject({ kind: "link", isValid: true, emailMismatch: false, invitedEmail: null, forward: { joinToken: "link" } });
76
+ });
77
+ });
78
+
79
+ describe("useCommunityInvite", () => {
80
+ it("reads the invite state for the provider's community with the token", async () => {
81
+ installAdapter([get("/public/communities/fan-club/invite", () => answer({}))]);
82
+ const { result } = renderHook(() => useCommunityInvite({ invite: "tok" }), { wrapper: wrapper({ user: account("invited@example.com") }) });
83
+ await waitFor(() => expect(result.current.isValid).toBe(true));
84
+ expect(calls("get", "/public/communities/fan-club/invite")[0].params).toMatchObject({ profileId: "profile-1", invite: "tok" });
85
+ });
86
+
87
+ it("a token the server cannot resolve at all (404) reads as not found, never as valid", async () => {
88
+ installAdapter([
89
+ get("/public/communities/fan-club/invite", () => {
90
+ throw { status: 404 };
91
+ }),
92
+ ]);
93
+ const { result } = renderHook(() => useCommunityInvite({ join: "gone" }), { wrapper: wrapper() });
94
+ await waitFor(() => expect(result.current.problem).toBe("not_found"));
95
+ expect(result.current.forward).toEqual({});
96
+ });
97
+
98
+ it("sends nothing without a token", async () => {
99
+ installAdapter([]);
100
+ const { result } = renderHook(() => useCommunityInvite({}), { wrapper: wrapper() });
101
+ expect(result.current.kind).toBeNull();
102
+ expect(calls("get", /\/invite$/)).toHaveLength(0);
103
+ });
104
+ });