@tribe-nest/forge 3.60.0 → 3.63.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 (220) hide show
  1. package/package.json +5 -2
  2. package/src/contexts/CartContext.tsx +34 -1
  3. package/src/contexts/PublicAuthContext.tsx +15 -3
  4. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  5. package/src/data/queries/useAccount.ts +33 -2
  6. package/src/data/queries/useAnalytics.ts +6 -6
  7. package/src/data/queries/useCallChat.ts +2 -2
  8. package/src/data/queries/useChat.ts +10 -1
  9. package/src/data/queries/useCheckouts.ts +2 -1
  10. package/src/data/queries/useCoachingAvailability.ts +10 -1
  11. package/src/data/queries/useCommunities.ts +2 -0
  12. package/src/data/queries/useCommunity.ts +362 -18
  13. package/src/data/queries/useCommunityAgent.ts +247 -0
  14. package/src/data/queries/useCommunityCalendar.ts +150 -0
  15. package/src/data/queries/useCommunityClassroom.ts +75 -0
  16. package/src/data/queries/useCommunityMembers.ts +108 -0
  17. package/src/data/queries/useCommunityModeration.ts +327 -0
  18. package/src/data/queries/useCommunityWiki.ts +137 -0
  19. package/src/data/queries/useCourses.ts +2 -0
  20. package/src/data/queries/useEmailSignIn.ts +79 -0
  21. package/src/data/queries/useEvents.ts +6 -1
  22. package/src/data/queries/useForms.ts +5 -1
  23. package/src/data/queries/useLeadMagnet.ts +6 -2
  24. package/src/data/queries/useMyBookings.ts +24 -1
  25. package/src/data/queries/useMyTickets.ts +24 -1
  26. package/src/data/queries/useNotifications.ts +6 -2
  27. package/src/data/queries/useOrders.ts +2 -1
  28. package/src/data/queries/usePWA.ts +111 -35
  29. package/src/data/queries/useSiteApp.ts +130 -0
  30. package/src/data/queries/useSubscriptions.ts +6 -1
  31. package/src/i18n/de.json +746 -1
  32. package/src/i18n/en.json +746 -1
  33. package/src/index.ts +20 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/seo/_tests/seo.spec.ts +326 -0
  37. package/src/seo/applySeoToHead.ts +98 -0
  38. package/src/seo/index.ts +9 -0
  39. package/src/seo/resolve.ts +211 -0
  40. package/src/seo/seoHead.ts +61 -0
  41. package/src/seo/siteSeoHead.ts +97 -0
  42. package/src/seo/types.ts +99 -0
  43. package/src/server/_tests/siteBootstrap.spec.ts +65 -0
  44. package/src/server/index.ts +33 -4
  45. package/src/types/models.ts +11 -0
  46. package/src/ui/analytics/ForgeAnalytics.tsx +134 -34
  47. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  48. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  49. package/src/ui/analytics/_tests/ForgeAnalytics.webVitals.spec.tsx +129 -0
  50. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  51. package/src/ui/analytics/useMetaEvents.ts +78 -0
  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/communityHarness.tsx +10 -3
  56. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  57. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  58. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  59. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  60. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  61. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  62. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  63. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  64. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  65. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  66. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  67. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  68. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  69. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  70. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  71. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  72. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  73. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  74. package/src/ui/headless/communities/index.ts +41 -0
  75. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  76. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  77. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  78. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  79. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  80. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  81. package/src/ui/headless/community/communityPoll.ts +297 -0
  82. package/src/ui/headless/community/countries.ts +123 -0
  83. package/src/ui/headless/community/index.ts +81 -1
  84. package/src/ui/headless/community/linkPreview.ts +71 -0
  85. package/src/ui/headless/community/richLexical.ts +27 -2
  86. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  87. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  88. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  89. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  90. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  91. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  92. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  93. package/src/ui/headless/downloads/index.ts +8 -0
  94. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  95. package/src/ui/headless/index.ts +13 -0
  96. package/src/ui/headless/memberHome/index.ts +10 -0
  97. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  98. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  99. package/src/ui/headless/portal/index.ts +57 -0
  100. package/src/ui/headless/portal/usePortal.ts +274 -0
  101. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  102. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  103. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  104. package/src/ui/headless/siteApp/appBar.ts +102 -0
  105. package/src/ui/headless/siteApp/appHome.ts +344 -0
  106. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  107. package/src/ui/headless/siteApp/index.ts +58 -0
  108. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  109. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  110. package/src/ui/headless/work/index.ts +2 -0
  111. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  112. package/src/ui/index.ts +11 -0
  113. package/src/ui/styled/AccountDashboard.tsx +712 -352
  114. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  115. package/src/ui/styled/ChatRoom.tsx +126 -16
  116. package/src/ui/styled/Checkout.tsx +8 -0
  117. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  118. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  119. package/src/ui/styled/CoachingDetail.tsx +2 -0
  120. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  121. package/src/ui/styled/CourseDetail.tsx +2 -0
  122. package/src/ui/styled/DownloadPage.tsx +263 -0
  123. package/src/ui/styled/EventConfirmation.tsx +10 -0
  124. package/src/ui/styled/EventDetail.tsx +10 -0
  125. package/src/ui/styled/FilmLibrary.tsx +22 -9
  126. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  127. package/src/ui/styled/ProductDetail.tsx +12 -0
  128. package/src/ui/styled/PwaRegistration.tsx +5 -1
  129. package/src/ui/styled/SignupForm.tsx +18 -3
  130. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  131. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  132. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  133. package/src/ui/styled/app/AppBar.tsx +298 -0
  134. package/src/ui/styled/app/AppHome.tsx +1139 -0
  135. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  136. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  137. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  138. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  139. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  140. package/src/ui/styled/app/index.ts +7 -0
  141. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  142. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  143. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  144. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  145. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  146. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  147. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  148. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  149. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  150. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  151. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  152. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  153. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  154. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  155. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  156. package/src/ui/styled/communities/index.ts +9 -0
  157. package/src/ui/styled/communities/shared.tsx +39 -11
  158. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  159. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  160. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  161. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  162. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  163. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  164. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  165. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  166. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  167. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  168. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  169. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  170. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  171. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  172. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  173. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  174. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  175. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  176. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  177. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  178. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  179. package/src/ui/styled/community/index.ts +37 -2
  180. package/src/ui/styled/forge-utilities.css +234 -0
  181. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  182. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  183. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  184. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  185. package/src/ui/styled/members/index.ts +8 -0
  186. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  187. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  188. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  189. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  190. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  191. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  192. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  193. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  194. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  195. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  196. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  197. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  198. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  199. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  200. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  201. package/src/ui/styled/portal/index.ts +13 -0
  202. package/src/ui/styled/portal/portalNav.ts +260 -0
  203. package/src/ui/styled/portal/shared.tsx +206 -0
  204. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  205. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  206. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  207. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  208. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  209. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  210. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  211. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  212. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  213. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  214. package/src/ui/styled/work/index.ts +1 -1
  215. package/src/utils/_tests/landing.spec.ts +39 -0
  216. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  217. package/src/utils/_tests/structuredDataSeo.spec.ts +247 -0
  218. package/src/utils/landing.ts +36 -10
  219. package/src/utils/metaEvents.ts +302 -0
  220. package/src/utils/structuredData.ts +339 -1
@@ -0,0 +1,52 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { renderHook, waitFor } from "@testing-library/react";
4
+ import { resolveAppHomeHref, useAppHomeHref, useSiteApp } from "../../../data/queries/useSiteApp";
5
+ import { calls, get, installAdapter, restoreAdapter, wrapper } from "./communityHarness";
6
+ import { siteApp } from "./siteAppHarness";
7
+
8
+ /**
9
+ * The site app's public settings, read for the host the visitor is on, and the
10
+ * Home rule built on them.
11
+ */
12
+
13
+ afterEach(restoreAdapter);
14
+
15
+ describe("useSiteApp", () => {
16
+ it("asks for the settings of the host the visitor is on", async () => {
17
+ installAdapter([get("/public/websites/app", () => siteApp())]);
18
+ const { result } = renderHook(() => useSiteApp(), { wrapper: wrapper({ community: null }) });
19
+ await waitFor(() => expect(result.current.data).toBeDefined());
20
+ const sent = calls("get", "/public/websites/app");
21
+ expect(sent).toHaveLength(1);
22
+ expect(sent[0]!.params).toEqual({ host: window.location.host });
23
+ });
24
+ });
25
+
26
+ describe("useAppHomeHref", () => {
27
+ it("is the creator's start page when they chose one", async () => {
28
+ installAdapter([get("/public/websites/app", () => siteApp({ startUrl: "/members" }))]);
29
+ const { result } = renderHook(() => useAppHomeHref(), { wrapper: wrapper({ community: null }) });
30
+ await waitFor(() => expect(result.current).toBe("/members"));
31
+ });
32
+
33
+ it("falls back to /i/app before the settings arrive and when the host is unknown", async () => {
34
+ installAdapter([
35
+ get("/public/websites/app", () => {
36
+ throw { status: 404 };
37
+ }),
38
+ ]);
39
+ const { result } = renderHook(() => useAppHomeHref(), { wrapper: wrapper({ community: null }) });
40
+ expect(result.current).toBe("/i/app");
41
+ await waitFor(() => expect(calls("get", "/public/websites/app")).toHaveLength(1));
42
+ expect(result.current).toBe("/i/app");
43
+ });
44
+
45
+ it("never sends Home off-site", () => {
46
+ expect(resolveAppHomeHref({ startUrl: "//evil.example" })).toBe("/i/app");
47
+ expect(resolveAppHomeHref({ startUrl: "https://evil.example" })).toBe("/i/app");
48
+ expect(resolveAppHomeHref({ startUrl: "" })).toBe("/i/app");
49
+ expect(resolveAppHomeHref(null)).toBe("/i/app");
50
+ expect(resolveAppHomeHref({ startUrl: "/start" })).toBe("/start");
51
+ });
52
+ });
@@ -0,0 +1,132 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import { useWikiMediaImage, wikiImageSource, wikiMediaImageStatus } from "../communities/useCommunityWiki";
5
+ import { wikiMediaRefreshMs } from "../../../data/queries/useCommunityWiki";
6
+ import { PROFILE_ID, account, calls, get, installAdapter, restoreAdapter, wrapper } from "./communityHarness";
7
+
8
+ /**
9
+ * Private wiki images. A page body names one as `kbmedia:<id>`, and the site
10
+ * exchanges it for a signed address that lives ten minutes, asked for with the
11
+ * page it sits on. These pin which `src` values render at all, what is sent,
12
+ * the refresh before expiry, the single re-request after a failed load, and
13
+ * the quiet unavailable state.
14
+ */
15
+
16
+ afterEach(restoreAdapter);
17
+
18
+ const MEDIA = "/public/communities/fan-club/wiki/start-here/media/m-1";
19
+
20
+ describe("wikiImageSource", () => {
21
+ it("keeps plain http(s) images and site paths as they are", () => {
22
+ expect(wikiImageSource("https://cdn.example.com/a.png")).toEqual({
23
+ kind: "url",
24
+ url: "https://cdn.example.com/a.png",
25
+ });
26
+ expect(wikiImageSource("http://example.com/a.png")).toEqual({ kind: "url", url: "http://example.com/a.png" });
27
+ expect(wikiImageSource("/uploads/a.png")).toEqual({ kind: "url", url: "/uploads/a.png" });
28
+ });
29
+
30
+ it("reads a private reference as its media id", () => {
31
+ expect(wikiImageSource("kbmedia:abc-123_X")).toEqual({ kind: "private", mediaId: "abc-123_X" });
32
+ });
33
+
34
+ it("never renders anything else", () => {
35
+ for (const src of [
36
+ "javascript:alert(1)",
37
+ "data:image/png;base64,AAAA",
38
+ "//evil.example.com/a.png",
39
+ "kbmedia:",
40
+ "kbmedia:../../etc",
41
+ "kb-private:abc",
42
+ "",
43
+ 42,
44
+ null,
45
+ ]) {
46
+ expect(wikiImageSource(src), String(src)).toBeNull();
47
+ }
48
+ });
49
+ });
50
+
51
+ describe("wikiMediaImageStatus", () => {
52
+ const base = {
53
+ isLoading: false,
54
+ isError: false,
55
+ url: "u1",
56
+ failedUrls: new Set<string>(),
57
+ retriedAfterFailure: false,
58
+ };
59
+
60
+ it("is loading until an address arrives, then ready", () => {
61
+ expect(wikiMediaImageStatus({ ...base, url: null, isLoading: true })).toBe("loading");
62
+ expect(wikiMediaImageStatus(base)).toBe("ready");
63
+ });
64
+
65
+ it("is unavailable on a refused or failed request", () => {
66
+ expect(wikiMediaImageStatus({ ...base, url: null, isError: true })).toBe("unavailable");
67
+ expect(wikiMediaImageStatus({ ...base, url: null })).toBe("unavailable");
68
+ });
69
+
70
+ it("waits for a fresh address after the first failed load, and gives up after the second", () => {
71
+ const failed = new Set(["u1"]);
72
+ expect(wikiMediaImageStatus({ ...base, failedUrls: failed })).toBe("loading");
73
+ expect(wikiMediaImageStatus({ ...base, failedUrls: failed, retriedAfterFailure: true })).toBe("unavailable");
74
+ expect(wikiMediaImageStatus({ ...base, url: "u2", failedUrls: failed, retriedAfterFailure: true })).toBe("ready");
75
+ });
76
+ });
77
+
78
+ describe("wikiMediaRefreshMs", () => {
79
+ it("refreshes a minute before expiry, never under 30 seconds", () => {
80
+ expect(wikiMediaRefreshMs(600)).toBe(540_000);
81
+ expect(wikiMediaRefreshMs(60)).toBe(30_000);
82
+ expect(wikiMediaRefreshMs(undefined)).toBe(540_000);
83
+ });
84
+ });
85
+
86
+ describe("useWikiMediaImage", () => {
87
+ it("asks for the signed address with the page it sits on", async () => {
88
+ installAdapter([get(MEDIA, () => ({ url: "https://signed.example.com/1", expiresInSeconds: 600 }))]);
89
+ const { result } = renderHook(() => useWikiMediaImage("start-here", "m-1"), {
90
+ wrapper: wrapper({ user: account() }),
91
+ });
92
+ expect(result.current.status).toBe("loading");
93
+ await waitFor(() => expect(result.current.status).toBe("ready"));
94
+ expect(result.current.url).toBe("https://signed.example.com/1");
95
+ expect(calls("get", MEDIA)[0].params).toEqual({ profileId: PROFILE_ID });
96
+ });
97
+
98
+ it("is unavailable when the server refuses, without retrying", async () => {
99
+ installAdapter([
100
+ get(MEDIA, () => {
101
+ throw { status: 404, message: "Not found" };
102
+ }),
103
+ ]);
104
+ const { result } = renderHook(() => useWikiMediaImage("start-here", "m-1"), { wrapper: wrapper() });
105
+ await waitFor(() => expect(result.current.status).toBe("unavailable"));
106
+ expect(calls("get", MEDIA)).toHaveLength(1);
107
+ });
108
+
109
+ it("replaces an address the browser could not load once, then gives up", async () => {
110
+ let n = 0;
111
+ installAdapter([get(MEDIA, () => ({ url: `https://signed.example.com/${++n}`, expiresInSeconds: 600 }))]);
112
+ const { result } = renderHook(() => useWikiMediaImage("start-here", "m-1"), {
113
+ wrapper: wrapper({ user: account() }),
114
+ });
115
+ await waitFor(() => expect(result.current.url).toBe("https://signed.example.com/1"));
116
+
117
+ act(() => result.current.onImageError());
118
+ await waitFor(() => expect(result.current.url).toBe("https://signed.example.com/2"));
119
+ expect(result.current.status).toBe("ready");
120
+
121
+ act(() => result.current.onImageError());
122
+ await waitFor(() => expect(result.current.status).toBe("unavailable"));
123
+ expect(calls("get", MEDIA)).toHaveLength(2);
124
+ });
125
+
126
+ it("asks for nothing without a media id", () => {
127
+ installAdapter([]);
128
+ const { result } = renderHook(() => useWikiMediaImage("start-here", undefined), { wrapper: wrapper() });
129
+ expect(result.current.status).toBe("unavailable");
130
+ expect(calls("get", /\/media\//)).toHaveLength(0);
131
+ });
132
+ });
@@ -0,0 +1,186 @@
1
+ import { useContext, useState } from "react";
2
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
3
+ import {
4
+ useCompleteEmailSignUp,
5
+ useStartEmailSignIn,
6
+ useVerifyEmailSignIn,
7
+ type EmailSignInSession,
8
+ } from "../../../data/queries/useEmailSignIn";
9
+
10
+ const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
11
+ const msg = (e: unknown) => (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
12
+
13
+ /**
14
+ * Where the email-first sign-in is:
15
+ *
16
+ * email the address, then Continue (emails a code).
17
+ * code the 6-digit code. Right for an address with an account: signed in.
18
+ * profile right, but the address has no account here yet: name and terms.
19
+ */
20
+ export type EmailCodeSignInStep = "email" | "code" | "profile";
21
+
22
+ export interface UseEmailCodeSignInOptions {
23
+ /** Called once signed in, by the code (`existing`) or after the name step (`new`). */
24
+ onSignedIn?: (result: { kind: "existing" | "new" }) => void;
25
+ /** Require the terms checkbox on the name step (default true). The server requires it regardless. */
26
+ requireAcceptance?: boolean;
27
+ /** Start with this email filled in. */
28
+ defaultEmail?: string;
29
+ }
30
+
31
+ /**
32
+ * Headless email-first sign-in: one email field, one code, and a name only for
33
+ * someone new. Nobody chooses between signing up and signing in.
34
+ *
35
+ * email -> code -> (signed in) | profile -> (signed in)
36
+ *
37
+ * The session is stored through `PublicAuthContext.adoptSession`, the same
38
+ * storage every other sign-in uses. Must sit inside a `PublicAuthProvider`.
39
+ */
40
+ export function useEmailCodeSignIn(opts: UseEmailCodeSignInOptions = {}) {
41
+ const auth = useContext(PublicAuthContext);
42
+ const start = useStartEmailSignIn();
43
+ const verify = useVerifyEmailSignIn();
44
+ const complete = useCompleteEmailSignUp();
45
+
46
+ const [step, setStep] = useState<EmailCodeSignInStep>("email");
47
+ const [email, setEmail] = useState(opts.defaultEmail ?? "");
48
+ const [code, setCode] = useState("");
49
+ const [ticket, setTicket] = useState<string | null>(null);
50
+ const [firstName, setFirstName] = useState("");
51
+ const [lastName, setLastName] = useState("");
52
+ const [acceptedTerms, setAcceptedTerms] = useState(false);
53
+ const [error, setError] = useState<string | null>(null);
54
+ const [resent, setResent] = useState(false);
55
+
56
+ const requireAcceptance = opts.requireAcceptance ?? true;
57
+ const trimmed = email.trim();
58
+
59
+ const signIn = (session: EmailSignInSession, kind: "existing" | "new") => {
60
+ auth?.adoptSession({ token: session.token, refreshToken: session.refreshToken, account: session.account });
61
+ opts.onSignedIn?.({ kind });
62
+ };
63
+
64
+ /** Step 1: email a code. */
65
+ const submitEmail = async () => {
66
+ setError(null);
67
+ if (!emailRe.test(trimmed)) {
68
+ setError("Please enter a valid email.");
69
+ return false;
70
+ }
71
+ try {
72
+ await start.mutateAsync({ email: trimmed });
73
+ setCode("");
74
+ setResent(false);
75
+ setStep("code");
76
+ return true;
77
+ } catch (e) {
78
+ setError(msg(e) || "Could not send the code.");
79
+ return false;
80
+ }
81
+ };
82
+
83
+ /** Step 2: redeem the code. Signs in an existing account, or opens the name step. */
84
+ const submitCode = async () => {
85
+ setError(null);
86
+ const value = code.trim();
87
+ if (!/^\d{6}$/.test(value)) {
88
+ setError("Enter the 6-digit code we emailed you.");
89
+ return false;
90
+ }
91
+ try {
92
+ const answer = await verify.mutateAsync({ email: trimmed, code: value });
93
+ if (answer.status === "needs_profile") {
94
+ setTicket(answer.ticket);
95
+ setStep("profile");
96
+ return true;
97
+ }
98
+ signIn(answer, "existing");
99
+ return true;
100
+ } catch (e) {
101
+ setError(msg(e) || "That code did not work.");
102
+ return false;
103
+ }
104
+ };
105
+
106
+ /** Step 3, for someone new: create the account and sign in. */
107
+ const submitProfile = async () => {
108
+ setError(null);
109
+ const first = firstName.trim();
110
+ const last = lastName.trim();
111
+ if (!first || !last) {
112
+ setError("Please enter your name.");
113
+ return false;
114
+ }
115
+ if (first.length > 100 || last.length > 100) {
116
+ setError("Please use a shorter name.");
117
+ return false;
118
+ }
119
+ if (requireAcceptance && !acceptedTerms) {
120
+ setError("You must accept the Terms and Privacy Policy.");
121
+ return false;
122
+ }
123
+ if (!ticket) {
124
+ setStep("email");
125
+ return false;
126
+ }
127
+ try {
128
+ const session = await complete.mutateAsync({
129
+ email: trimmed,
130
+ ticket,
131
+ firstName: first,
132
+ lastName: last,
133
+ acceptedPrivacy: true,
134
+ });
135
+ signIn(session, "new");
136
+ return true;
137
+ } catch (e) {
138
+ setError(msg(e) || "Could not create your account.");
139
+ return false;
140
+ }
141
+ };
142
+
143
+ /** Email another code (throttled to one per 60 seconds server-side). */
144
+ const resendCode = async () => {
145
+ setError(null);
146
+ setResent(false);
147
+ try {
148
+ await start.mutateAsync({ email: trimmed });
149
+ setResent(true);
150
+ } catch (e) {
151
+ setError(msg(e) || "Could not resend the code.");
152
+ }
153
+ };
154
+
155
+ /** Back to the email field, to correct a mistyped address. */
156
+ const changeEmail = () => {
157
+ setStep("email");
158
+ setCode("");
159
+ setTicket(null);
160
+ setResent(false);
161
+ setError(null);
162
+ };
163
+
164
+ return {
165
+ step,
166
+ email,
167
+ setEmail,
168
+ code,
169
+ setCode,
170
+ firstName,
171
+ setFirstName,
172
+ lastName,
173
+ setLastName,
174
+ acceptedTerms,
175
+ setAcceptedTerms,
176
+ error,
177
+ /** True right after a resend succeeded, until the next action. */
178
+ resent,
179
+ isSubmitting: start.isPending || verify.isPending || complete.isPending,
180
+ submitEmail,
181
+ submitCode,
182
+ submitProfile,
183
+ resendCode,
184
+ changeEmail,
185
+ };
186
+ }
@@ -11,8 +11,27 @@ export interface UseSignupFormOptions {
11
11
  couponCode?: string;
12
12
  /** Require the terms/privacy checkbox before submit (default true). */
13
13
  requireAcceptance?: boolean;
14
+ /**
15
+ * Start with this email filled in. When omitted, an address handed over by a
16
+ * client-portal invite in this tab (`rememberSignupEmail`) is used once.
17
+ */
18
+ defaultEmail?: string;
14
19
  }
15
20
 
21
+ const SIGNUP_EMAIL_PREFILL_KEY = "forge:signup-email";
22
+
23
+ /** The invite page's handed-over email, read and cleared in one go. */
24
+ const takePrefilledEmail = (): string => {
25
+ if (typeof window === "undefined") return "";
26
+ try {
27
+ const value = window.sessionStorage.getItem(SIGNUP_EMAIL_PREFILL_KEY) ?? "";
28
+ if (value) window.sessionStorage.removeItem(SIGNUP_EMAIL_PREFILL_KEY);
29
+ return value;
30
+ } catch {
31
+ return "";
32
+ }
33
+ };
34
+
16
35
  /**
17
36
  * Headless account signup. Bring your own UI (or use the styled <SignupForm>).
18
37
  *
@@ -27,7 +46,7 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
27
46
 
28
47
  const [firstName, setFirstName] = useState("");
29
48
  const [lastName, setLastName] = useState("");
30
- const [email, setEmail] = useState("");
49
+ const [email, setEmail] = useState(() => opts.defaultEmail ?? takePrefilledEmail());
31
50
  const [password, setPassword] = useState("");
32
51
  const [acceptedTerms, setAcceptedTerms] = useState(false);
33
52
  const [error, setError] = useState<string | null>(null);
@@ -26,8 +26,11 @@ const nextId = () => `att-${Date.now()}-${counter++}`;
26
26
  * upload them to R2 via presigned URLs, and hand back `ChatAttachment[]` to send.
27
27
  * Mirrors the admin `useUploadFiles` flow but Forge-native (uses the tenant client).
28
28
  */
29
- export function useChatAttachmentUpload() {
29
+ export function useChatAttachmentUpload(options: { presignPath?: string } = {}) {
30
30
  const { client, profileId } = useForge();
31
+ // Community chat signs uploads at its own member-scoped endpoint. A surface
32
+ // with its own access rule (the client portal's Messages) names its own.
33
+ const presignPath = options.presignPath ?? "/public/chat/attachments/presigned-url";
31
34
  const [pending, setPending] = useState<PendingAttachment[]>([]);
32
35
  const [error, setError] = useState<string | null>(null);
33
36
  const pendingRef = useRef<PendingAttachment[]>([]);
@@ -89,10 +92,10 @@ export function useChatAttachmentUpload() {
89
92
  // Member-scoped presigned endpoint. The admin `/uploads/presigned-url`
90
93
  // route requires profile write permissions, which website members
91
94
  // don't have — using it made file-only messages fail silently.
92
- const { data } = await client.post<{ presignedUrl: string; remoteUrl: string }>(
93
- "/public/chat/attachments/presigned-url",
94
- { fileName: item.file.name, profileId },
95
- );
95
+ const { data } = await client.post<{ presignedUrl: string; remoteUrl: string }>(presignPath, {
96
+ fileName: item.file.name,
97
+ profileId,
98
+ });
96
99
  await axios.put(data.presignedUrl, item.file, {
97
100
  headers: { "Content-Type": item.file.type || "application/octet-stream" },
98
101
  onUploadProgress: (e) => {
@@ -116,7 +119,7 @@ export function useChatAttachmentUpload() {
116
119
  }
117
120
 
118
121
  return uploaded;
119
- }, [client, profileId, patch]);
122
+ }, [client, profileId, patch, presignPath]);
120
123
 
121
124
  return {
122
125
  pending,
@@ -15,6 +15,8 @@ import {
15
15
  type ChatChannel,
16
16
  type ChatMessage,
17
17
  } from "../../../data/queries/useChat";
18
+ import { useOpenCommunityAgentDm, COMMUNITY_AGENT_DM_KEY_PREFIX } from "../../../data/queries/useCommunityAgent";
19
+ import { useChatAgentThinking } from "../community/useCommunityAgent";
18
20
 
19
21
  /**
20
22
  * Headless community chat room: channel list + unread counts, paginated messages
@@ -23,7 +25,7 @@ import {
23
25
  * and same-tier member DMs.
24
26
  *
25
27
  * Composes the `useChat*` data hooks and `useChatSocket`. This is the single
26
- * primitive a creator needs to rebuild the chat page — render however you like.
28
+ * primitive a creator needs to rebuild the chat page. Render however you like.
27
29
  */
28
30
  export function useChatRoom() {
29
31
  const { user } = usePublicAuth();
@@ -33,6 +35,7 @@ export function useChatRoom() {
33
35
  const { data: unreadCounts = [] } = useChatUnreadCounts();
34
36
  const { data: memberDirectory = [] } = useChatMemberDirectory();
35
37
  const createMemberDm = useCreateMemberDm();
38
+ const openAgentDm = useOpenCommunityAgentDm();
36
39
  const markRead = useMarkChatRead();
37
40
 
38
41
  const [selectedChannel, setSelectedChannel] = useState<ChatChannel | null>(null);
@@ -138,6 +141,9 @@ export function useChatRoom() {
138
141
  };
139
142
  }, [socket, queryClient, user?.id]);
140
143
 
144
+ // The community AI writing an answer (in a thread, or in its DM).
145
+ const agentThinking = useChatAgentThinking(socket);
146
+
141
147
  // Join/leave the selected channel room.
142
148
  useEffect(() => {
143
149
  if (!selectedChannel?.id || !socket?.connected) return;
@@ -194,6 +200,28 @@ export function useChatRoom() {
194
200
  [createMemberDm],
195
201
  );
196
202
 
203
+ /**
204
+ * Open the member's DM with the community AI and select it. The DM is an
205
+ * ordinary chat channel; until the refreshed channel list carries it, a
206
+ * stand-in with the agent's name is selected so the panel opens at once.
207
+ */
208
+ const startAgentDm = useCallback(async () => {
209
+ const result = await openAgentDm.mutateAsync();
210
+ const listed = channels.find((c) => c.id === result.channelId);
211
+ const channel: ChatChannel = listed ?? {
212
+ id: result.channelId,
213
+ name: result.agent.enabled ? result.agent.name : null,
214
+ type: "dm",
215
+ description: null,
216
+ otherParticipant: null,
217
+ dmKey: `${COMMUNITY_AGENT_DM_KEY_PREFIX}${result.agent.enabled ? result.agent.id : ""}`,
218
+ };
219
+ setSelectedChannel(channel);
220
+ setActiveThreadParent(null);
221
+ setTypingUsers([]);
222
+ return channel;
223
+ }, [openAgentDm, channels]);
224
+
197
225
  return {
198
226
  channels,
199
227
  channelsLoading,
@@ -219,5 +247,11 @@ export function useChatRoom() {
219
247
  memberDirectory,
220
248
  startMemberDm,
221
249
  isStartingDm: createMemberDm.isPending,
250
+ // The community AI
251
+ startAgentDm,
252
+ isStartingAgentDm: openAgentDm.isPending,
253
+ agentDmError: openAgentDm.error,
254
+ /** Whether the community AI is answering in this channel (null thread = the channel itself, as in its DM). */
255
+ isAgentThinking: agentThinking.isThinking,
222
256
  };
223
257
  }
@@ -53,3 +53,44 @@ export { useCommunityHome, type UseCommunityHomeOptions } from "./useCommunityHo
53
53
  export { useMyMemberships, toMyMembershipEntry, type MyMembershipEntry } from "./useMyMemberships";
54
54
  export { CommunityGate, communityGateAllows, type CommunityGateProps, type CommunityGateState } from "./CommunityGate";
55
55
  export { communitiesListPath, communityAboutPath, communityHomePath } from "./paths";
56
+ export {
57
+ useCommunityWiki,
58
+ useCommunityWikiPage,
59
+ shapeWikiList,
60
+ defaultWikiPageSlug,
61
+ parseWikiContent,
62
+ extractWikiToc,
63
+ wikiHeadingSlug,
64
+ lexicalNodeText,
65
+ wikiImageSource,
66
+ wikiMediaImageStatus,
67
+ wikiUnlockParts,
68
+ useWikiMediaImage,
69
+ type WikiImageSource,
70
+ type WikiMediaImageStatus,
71
+ WIKI_TOC_MIN_HEADINGS,
72
+ type CommunityWikiTocEntry,
73
+ type UseCommunityWikiOptions,
74
+ } from "./useCommunityWiki";
75
+ export {
76
+ useCommunityClassroom,
77
+ classroomProgressPercent,
78
+ classroomCourseState,
79
+ classroomUnlockParts,
80
+ classroomCourseHref,
81
+ type ClassroomCourseState,
82
+ } from "./useCommunityClassroom";
83
+ export {
84
+ useCommunityCalendar,
85
+ calendarDayKey,
86
+ calendarMonthStart,
87
+ calendarMonthWindow,
88
+ calendarItemIsOver,
89
+ groupCalendarItems,
90
+ rsvpErrorCode,
91
+ nextRsvp,
92
+ callJoinable,
93
+ type CommunityCalendarDay,
94
+ type CommunityRsvpErrorCode,
95
+ type UseCommunityCalendarOptions,
96
+ } from "./useCommunityCalendar";