@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,148 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { ForgeClientProvider } from "../../../../provider/ForgeProvider";
5
+ import { PublicAuthContext } from "../../../../contexts/PublicAuthContext";
6
+ import { ForgeThemeProvider } from "../../../theme/ForgeThemeProvider";
7
+ import type { PortalSummary } from "../../../headless/portal/usePortal";
8
+ import { PortalShell } from "../PortalShell";
9
+ import { PortalHome } from "../PortalHome";
10
+
11
+ /**
12
+ * The portal's home, rendered through `react-dom/server` with the summary
13
+ * SEEDED into the query cache, so the real `usePortalSummary` (and its key)
14
+ * stays in the path.
15
+ */
16
+
17
+ const PROFILE_ID = "profile-1";
18
+ const ACCOUNT_ID = "account-1";
19
+
20
+ const member = {
21
+ id: ACCOUNT_ID,
22
+ email: "ada@example.com",
23
+ firstName: "Ada",
24
+ lastName: "Client",
25
+ kind: "fan",
26
+ status: "active",
27
+ createdAt: "2026-01-01T00:00:00.000Z",
28
+ updatedAt: "2026-01-01T00:00:00.000Z",
29
+ membership: null,
30
+ };
31
+
32
+ const sections = {
33
+ projects: 1,
34
+ invoices: 1,
35
+ documents: 1,
36
+ bookings: 0,
37
+ memberships: 0,
38
+ courses: 0,
39
+ films: 0,
40
+ orders: 0,
41
+ tickets: 0,
42
+ waitlist: 0,
43
+ saved: 0,
44
+ messages: 0,
45
+ files: 0,
46
+ };
47
+
48
+ function render(summary: PortalSummary): string {
49
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
50
+ queryClient.setQueryData(["portal-summary", PROFILE_ID, ACCOUNT_ID], summary);
51
+ queryClient.setQueryData(["work-portal-projects", PROFILE_ID], []);
52
+ return renderToStaticMarkup(
53
+ <QueryClientProvider client={queryClient}>
54
+ <ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
55
+ <PublicAuthContext.Provider
56
+ value={{ user: member, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}
57
+ >
58
+ <ForgeThemeProvider
59
+ theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
60
+ >
61
+ <PortalShell section="home">
62
+ <PortalHome />
63
+ </PortalShell>
64
+ </ForgeThemeProvider>
65
+ </PublicAuthContext.Provider>
66
+ </ForgeClientProvider>
67
+ </QueryClientProvider>,
68
+ );
69
+ }
70
+
71
+ describe("PortalHome", () => {
72
+ it("leads with each thing that needs the person, as an action on the exact item", () => {
73
+ const html = render({
74
+ sections,
75
+ lastActivity: { work: "2026-09-30T00:00:00Z", library: null },
76
+ attention: [
77
+ {
78
+ kind: "invoice_overdue",
79
+ id: "inv-1",
80
+ invoiceNumber: "INV-1042",
81
+ total: "1200.00",
82
+ currency: "USD",
83
+ dueDate: "2026-09-01T00:00:00Z",
84
+ projectId: "p1",
85
+ projectName: "Website redesign",
86
+ },
87
+ {
88
+ kind: "approval_requested",
89
+ id: "req-1",
90
+ milestoneId: "m1",
91
+ milestoneName: "Homepage design",
92
+ projectId: "p1",
93
+ projectName: "Website redesign",
94
+ requestedAt: "2026-09-29T00:00:00Z",
95
+ note: "Ready for your eyes",
96
+ },
97
+ {
98
+ kind: "document_to_sign",
99
+ id: "doc-1",
100
+ title: "Engagement letter",
101
+ token: "tok-abc",
102
+ sentAt: "2026-09-28T00:00:00Z",
103
+ expiresAt: null,
104
+ },
105
+ ],
106
+ });
107
+
108
+ expect(html).toContain("Ada");
109
+ expect(html).toContain("3 things need you.");
110
+
111
+ expect(html).toContain("Pay invoice INV-1042");
112
+ expect(html).toContain("$1,200.00");
113
+ expect(html).toContain('href="/i/invoice/inv-1"');
114
+
115
+ expect(html).toContain("Review Homepage design");
116
+ expect(html).toContain('href="/i/account/projects/p1#approvals"');
117
+
118
+ expect(html).toContain("Sign Engagement letter");
119
+ expect(html).toContain('href="/i/documents/tok-abc"');
120
+
121
+ // In the server's order, which ranks overdue money first.
122
+ expect(html.indexOf("INV-1042")).toBeLessThan(html.indexOf("Homepage design"));
123
+ expect(html.indexOf("Homepage design")).toBeLessThan(html.indexOf("Engagement letter"));
124
+ });
125
+
126
+ it("says the person is caught up, and draws no list, when nothing needs them", () => {
127
+ const html = render({ sections, lastActivity: { work: null, library: null }, attention: [] });
128
+ expect(html).toContain("You&#x27;re all caught up.");
129
+ expect(html).not.toContain('data-testid="portal-needs-you"');
130
+ });
131
+
132
+ it("draws the client's sections and badges them, and no fan sections", () => {
133
+ const html = render({
134
+ sections,
135
+ lastActivity: { work: "2026-09-30T00:00:00Z", library: null },
136
+ attention: [
137
+ { kind: "document_to_sign", id: "doc-1", title: "Letter", token: "t", sentAt: "2026-09-28T00:00:00Z", expiresAt: null },
138
+ ],
139
+ });
140
+ expect(html).toContain('data-testid="portal-nav-projects"');
141
+ expect(html).toContain('data-testid="portal-nav-invoices"');
142
+ expect(html).toContain('data-testid="portal-nav-documents"');
143
+ expect(html).not.toContain('data-testid="portal-nav-films"');
144
+ expect(html).not.toContain('data-testid="portal-nav-orders"');
145
+ expect(html).toContain('href="/i/account/documents"');
146
+ expect(html).toContain("1 need you");
147
+ });
148
+ });
@@ -0,0 +1,168 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { ForgeClientProvider } from "../../../../provider/ForgeProvider";
5
+ import { PublicAuthContext } from "../../../../contexts/PublicAuthContext";
6
+ import { ForgeThemeProvider } from "../../../theme/ForgeThemeProvider";
7
+ import {
8
+ portalMessagesInOrder,
9
+ portalMessagesKey,
10
+ portalThreadKey,
11
+ type PortalMessage,
12
+ type PortalMessagesPage,
13
+ type PortalThreadState,
14
+ } from "../../../headless/portal/usePortalMessages";
15
+ import { PortalMessages } from "../PortalMessages";
16
+
17
+ /**
18
+ * The Messages page, rendered through `react-dom/server` with the thread and
19
+ * the first page of messages SEEDED into the query cache, so the real hooks
20
+ * (and their keys) stay in the path.
21
+ */
22
+
23
+ const PROFILE_ID = "profile-1";
24
+ const ACCOUNT_ID = "account-1";
25
+
26
+ const member = {
27
+ id: ACCOUNT_ID,
28
+ email: "ada@example.com",
29
+ firstName: "Ada",
30
+ lastName: "Client",
31
+ kind: "fan",
32
+ status: "active",
33
+ createdAt: "2026-01-01T00:00:00.000Z",
34
+ updatedAt: "2026-01-01T00:00:00.000Z",
35
+ membership: null,
36
+ };
37
+
38
+ const MINE: PortalMessage = {
39
+ id: "m-1",
40
+ content: "Can we move the deadline?",
41
+ attachments: [
42
+ { url: "https://cdn.test/brief.pdf", fileName: "brief.pdf", fileSize: 2048, fileType: "application/pdf" },
43
+ ],
44
+ createdAt: "2026-09-30T10:00:00.000Z",
45
+ fromTeam: false,
46
+ isMine: true,
47
+ senderName: "Ada",
48
+ };
49
+ const REPLY: PortalMessage = {
50
+ id: "m-2",
51
+ content: "Friday works for us.",
52
+ attachments: [],
53
+ createdAt: "2026-09-30T11:00:00.000Z",
54
+ fromTeam: true,
55
+ isMine: false,
56
+ senderName: "Ava Rivers Studio",
57
+ };
58
+
59
+ function render(state: PortalThreadState, pages: PortalMessagesPage[] | null): string {
60
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
61
+ queryClient.setQueryData(portalThreadKey(PROFILE_ID, ACCOUNT_ID), state);
62
+ if (pages) {
63
+ queryClient.setQueryData(portalMessagesKey(PROFILE_ID, ACCOUNT_ID), {
64
+ pages,
65
+ pageParams: pages.map((_, i) => (i === 0 ? undefined : `cursor-${i}`)),
66
+ });
67
+ }
68
+ return renderToStaticMarkup(
69
+ <QueryClientProvider client={queryClient}>
70
+ <ForgeClientProvider apiUrl="http://api.test/" profileId={PROFILE_ID}>
71
+ <PublicAuthContext.Provider
72
+ value={{ user: member, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}
73
+ >
74
+ <ForgeThemeProvider
75
+ theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
76
+ >
77
+ <PortalMessages />
78
+ </ForgeThemeProvider>
79
+ </PublicAuthContext.Provider>
80
+ </ForgeClientProvider>
81
+ </QueryClientProvider>,
82
+ );
83
+ }
84
+
85
+ const THREAD = {
86
+ id: "thread-1",
87
+ createdAt: "2026-09-30T10:00:00.000Z",
88
+ unreadCount: 0,
89
+ lastMessageAt: "2026-09-30T11:00:00.000Z",
90
+ lastPreview: "Friday works for us.",
91
+ };
92
+
93
+ describe("PortalMessages", () => {
94
+ it("renders the conversation oldest first: mine on the right, the creator's reply signed by the brand", () => {
95
+ const html = render({ allowed: true, creator: { name: "Ava Rivers Studio" }, thread: THREAD }, [
96
+ { data: [MINE, REPLY], nextCursor: null },
97
+ ]);
98
+ expect(html).toContain('data-testid="portal-messages"');
99
+ expect(html).toContain(">Messages<");
100
+ const mineAt = html.indexOf('data-testid="portal-message-mine"');
101
+ const theirsAt = html.indexOf('data-testid="portal-message-theirs"');
102
+ expect(mineAt).toBeGreaterThan(-1);
103
+ expect(theirsAt).toBeGreaterThan(mineAt);
104
+ expect(html).toContain("Can we move the deadline?");
105
+ expect(html).toContain("Friday works for us.");
106
+ expect(html).toContain("Ava Rivers Studio ·");
107
+ expect(html).toContain("You ·");
108
+ // The attachment renders through the chat transport's own component.
109
+ expect(html).toContain("brief.pdf");
110
+ expect(html).toContain('aria-label="Your conversation with Ava Rivers Studio"');
111
+ expect(html).toContain('data-testid="portal-messages-composer"');
112
+ expect(html).not.toContain('data-testid="portal-messages-earlier"');
113
+ });
114
+
115
+ it("offers earlier messages when the server has more", () => {
116
+ const html = render({ allowed: true, creator: { name: "Ava" }, thread: THREAD }, [
117
+ { data: [REPLY], nextCursor: "m-2" },
118
+ ]);
119
+ expect(html).toContain('data-testid="portal-messages-earlier"');
120
+ });
121
+
122
+ it("an empty conversation invites the first message, with a composer", () => {
123
+ const html = render({ allowed: true, creator: { name: "Ava Rivers Studio" }, thread: null }, [
124
+ { data: [], nextCursor: null },
125
+ ]);
126
+ expect(html).toContain('data-testid="portal-empty"');
127
+ expect(html).toContain("Ask Ava Rivers Studio anything about your project, booking or order");
128
+ expect(html).toContain('data-testid="portal-messages-composer"');
129
+ expect(html).toContain('placeholder="Write a message"');
130
+ });
131
+
132
+ it("a person who may not message is told so, and gets no composer", () => {
133
+ const html = render({ allowed: false, creator: { name: "Ava Rivers Studio" }, thread: null }, [
134
+ { data: [], nextCursor: null },
135
+ ]);
136
+ expect(html).toContain("Messages are not open yet");
137
+ expect(html).not.toContain('data-testid="portal-messages-composer"');
138
+ });
139
+
140
+ it("every class it renders is fg: prefixed (lucide's own icon classes aside)", () => {
141
+ const html = render({ allowed: true, creator: { name: "Ava" }, thread: THREAD }, [
142
+ { data: [MINE, REPLY], nextCursor: "m-1" },
143
+ ]);
144
+ const classes = [...html.matchAll(/class="([^"]*)"/g)].flatMap((m) => m[1].split(/\s+/)).filter(Boolean);
145
+ expect(classes.length).toBeGreaterThan(0);
146
+ expect(classes.filter((c) => !c.startsWith("fg:") && !c.startsWith("lucide"))).toEqual([]);
147
+ });
148
+
149
+ it("orders pages: the newest page last, each oldest first inside", () => {
150
+ const ordered = portalMessagesInOrder([
151
+ {
152
+ data: [
153
+ { ...REPLY, id: "c" },
154
+ { ...REPLY, id: "d" },
155
+ ],
156
+ nextCursor: "c",
157
+ },
158
+ {
159
+ data: [
160
+ { ...MINE, id: "a" },
161
+ { ...MINE, id: "b" },
162
+ ],
163
+ nextCursor: null,
164
+ },
165
+ ]);
166
+ expect(ordered.map((m) => m.id)).toEqual(["a", "b", "c", "d"]);
167
+ });
168
+ });
@@ -0,0 +1,132 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import type { ReactNode } from "react";
6
+ import { createSharedContext } from "../../../../runtime/registry";
7
+ import { PublicAuthContext } from "../../../../contexts/PublicAuthContext";
8
+ import { ForgeThemeProvider } from "../../../theme/ForgeThemeProvider";
9
+ import { PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY } from "../../../../client/tokenStorage";
10
+ import { PortalPreviewBanner, formatPreviewEnd } from "../PortalPreviewBanner";
11
+
12
+ /**
13
+ * The "Previewing as ..." banner. jsdom, because Exit is behaviour: it must end
14
+ * the session on the server, drop the token from this browser and leave.
15
+ *
16
+ * The Forge client context is provided directly (it is a shared context keyed
17
+ * by name), so `client.post` is a spy and nothing reaches a network.
18
+ */
19
+
20
+ const ForgeContext = createSharedContext<unknown>("forgeClient", null);
21
+
22
+ const EXPIRES_AT = "2026-10-01T14:32:00.000Z";
23
+
24
+ function setup(user: Record<string, unknown> | null) {
25
+ const post = vi.fn().mockResolvedValue({ data: {} });
26
+ const setToken = vi.fn();
27
+ const queryClient = new QueryClient();
28
+ const clear = vi.spyOn(queryClient, "clear");
29
+ const wrapper = ({ children }: { children: ReactNode }) => (
30
+ <QueryClientProvider client={queryClient}>
31
+ <ForgeContext.Provider value={{ client: { post }, setToken, profileId: "profile-1" }}>
32
+ <PublicAuthContext.Provider value={{ user } as never}>
33
+ <ForgeThemeProvider
34
+ theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
35
+ >
36
+ {children}
37
+ </ForgeThemeProvider>
38
+ </PublicAuthContext.Provider>
39
+ </ForgeContext.Provider>
40
+ </QueryClientProvider>
41
+ );
42
+ render(<PortalPreviewBanner />, { wrapper });
43
+ return { post, setToken, clear };
44
+ }
45
+
46
+ const pat = {
47
+ id: "account-1",
48
+ email: "pat@example.com",
49
+ firstName: "Pat",
50
+ lastName: "Client",
51
+ kind: "fan",
52
+ status: "active",
53
+ createdAt: "2026-01-01T00:00:00.000Z",
54
+ updatedAt: "2026-01-01T00:00:00.000Z",
55
+ };
56
+
57
+ describe("PortalPreviewBanner", () => {
58
+ let assign: ReturnType<typeof vi.fn>;
59
+
60
+ beforeEach(() => {
61
+ localStorage.clear();
62
+ assign = vi.fn();
63
+ vi.stubGlobal("location", { ...window.location, assign, href: "http://site.test/i/account", search: "" });
64
+ });
65
+
66
+ afterEach(() => {
67
+ cleanup();
68
+ vi.unstubAllGlobals();
69
+ });
70
+
71
+ it("renders nothing for an ordinary session", () => {
72
+ setup({ ...pat, preview: null });
73
+ expect(screen.queryByRole("status")).toBeNull();
74
+ expect(screen.queryByRole("button")).toBeNull();
75
+ });
76
+
77
+ it("renders nothing when signed out", () => {
78
+ setup(null);
79
+ expect(screen.queryByRole("status")).toBeNull();
80
+ });
81
+
82
+ it("names the client, says read-only, and shows when it ends", () => {
83
+ setup({ ...pat, preview: { previewedByName: "Dev Tribe", expiresAt: EXPIRES_AT } });
84
+ const banner = screen.getByRole("status");
85
+ expect(banner.textContent).toContain(
86
+ `Previewing as Pat Client. Read-only, ends ${formatPreviewEnd(EXPIRES_AT, "en")}.`,
87
+ );
88
+ expect(screen.getByRole("button", { name: "Exit preview" })).toBeTruthy();
89
+ });
90
+
91
+ it("falls back to the email when the client has no name", () => {
92
+ setup({ ...pat, firstName: "", lastName: "", preview: { previewedByName: "Dev", expiresAt: EXPIRES_AT } });
93
+ expect(screen.getByRole("status").textContent).toContain("Previewing as pat@example.com.");
94
+ });
95
+
96
+ it("Exit ends the session on the server, forgets the token and the cache, and goes home", async () => {
97
+ localStorage.setItem(PUBLIC_ACCESS_TOKEN_KEY, "preview-token");
98
+ localStorage.setItem(PUBLIC_REFRESH_TOKEN_KEY, "stale-refresh");
99
+ const { post, setToken, clear } = setup({
100
+ ...pat,
101
+ preview: { previewedByName: "Dev Tribe", expiresAt: EXPIRES_AT },
102
+ });
103
+
104
+ await act(async () => {
105
+ fireEvent.click(screen.getByRole("button", { name: "Exit preview" }));
106
+ });
107
+
108
+ expect(post).toHaveBeenCalledWith("/public/sessions/preview/end");
109
+ expect(localStorage.getItem(PUBLIC_ACCESS_TOKEN_KEY)).toBeNull();
110
+ expect(localStorage.getItem(PUBLIC_REFRESH_TOKEN_KEY)).toBeNull();
111
+ expect(setToken).toHaveBeenCalledWith(null);
112
+ expect(clear).toHaveBeenCalled();
113
+ expect(assign).toHaveBeenCalledWith("/");
114
+ });
115
+
116
+ it("Exit still leaves this browser signed out when the server call fails", async () => {
117
+ localStorage.setItem(PUBLIC_ACCESS_TOKEN_KEY, "preview-token");
118
+ const { post, setToken } = setup({
119
+ ...pat,
120
+ preview: { previewedByName: "Dev Tribe", expiresAt: EXPIRES_AT },
121
+ });
122
+ post.mockRejectedValueOnce(new Error("network"));
123
+
124
+ await act(async () => {
125
+ fireEvent.click(screen.getByRole("button", { name: "Exit preview" }));
126
+ });
127
+
128
+ expect(localStorage.getItem(PUBLIC_ACCESS_TOKEN_KEY)).toBeNull();
129
+ expect(setToken).toHaveBeenCalledWith(null);
130
+ expect(assign).toHaveBeenCalledWith("/");
131
+ });
132
+ });
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildPortalNav } from "../portalNav";
3
+ import type { PortalSummary } from "../../../headless/portal/usePortal";
4
+
5
+ /**
6
+ * What the portal draws in its navigation, and in which order.
7
+ *
8
+ * The rule the research behind the portal was clearest about: no section the
9
+ * person has nothing in. A fan never sees "Projects", a client never sees
10
+ * "Films". The exception is the page being shown, so an emailed link never
11
+ * lands somewhere the navigation disowns.
12
+ */
13
+
14
+ const empty = {
15
+ projects: 0,
16
+ invoices: 0,
17
+ documents: 0,
18
+ bookings: 0,
19
+ memberships: 0,
20
+ courses: 0,
21
+ films: 0,
22
+ orders: 0,
23
+ tickets: 0,
24
+ waitlist: 0,
25
+ saved: 0,
26
+ messages: 0,
27
+ files: 0,
28
+ };
29
+
30
+ const summary = (overrides: Partial<PortalSummary> = {}): PortalSummary => ({
31
+ sections: { ...empty },
32
+ lastActivity: { work: null, library: null },
33
+ attention: [],
34
+ ...overrides,
35
+ });
36
+
37
+ const keys = (nav: ReturnType<typeof buildPortalNav>) => nav.groups.flatMap((g) => g.items.map((i) => i.key));
38
+
39
+ describe("buildPortalNav", () => {
40
+ it("draws only the always-present items for someone with nothing yet", () => {
41
+ const nav = buildPortalNav(summary(), "home");
42
+ expect(keys(nav)).toEqual(["home", "activity", "settings", "notifications"]);
43
+ expect(nav.groups.map((g) => g.group)).toEqual(["start", "account"]);
44
+ expect(nav.primary).toBeNull();
45
+ });
46
+
47
+ it("draws only the always-present items until the summary arrives", () => {
48
+ // Not every section flashing in and then vanishing.
49
+ expect(keys(buildPortalNav(undefined, "home"))).toEqual(["home", "activity", "settings", "notifications"]);
50
+ });
51
+
52
+ it("draws a section once the person has something in it", () => {
53
+ const nav = buildPortalNav(summary({ sections: { ...empty, orders: 2, tickets: 1 } }), "home");
54
+ expect(keys(nav)).toEqual(["home", "activity", "orders", "tickets", "settings", "notifications"]);
55
+ });
56
+
57
+ it("keeps the section being shown, even when it is empty", () => {
58
+ // An emailed link to /i/account/invoices for someone whose invoices are
59
+ // all on another profile still lands on a page its navigation owns.
60
+ const nav = buildPortalNav(summary(), "invoices");
61
+ expect(keys(nav)).toContain("invoices");
62
+ });
63
+
64
+ it("puts work first for a client and the library first for a fan", () => {
65
+ const sections = { ...empty, projects: 1, orders: 1 };
66
+ const client = buildPortalNav(
67
+ summary({ sections, lastActivity: { work: "2026-09-30T10:00:00Z", library: "2026-01-01T00:00:00Z" } }),
68
+ "home",
69
+ );
70
+ const fan = buildPortalNav(
71
+ summary({ sections, lastActivity: { work: "2026-01-01T00:00:00Z", library: "2026-09-30T10:00:00Z" } }),
72
+ "home",
73
+ );
74
+ expect(client.groups.map((g) => g.group)).toEqual(["start", "work", "library", "account"]);
75
+ expect(client.primary?.key).toBe("projects");
76
+ expect(fan.groups.map((g) => g.group)).toEqual(["start", "library", "work", "account"]);
77
+ expect(fan.primary?.key).toBe("orders");
78
+ });
79
+
80
+ it("counts what needs the person against the section it belongs to", () => {
81
+ const nav = buildPortalNav(
82
+ summary({
83
+ sections: { ...empty, invoices: 3, documents: 1, projects: 1 },
84
+ attention: [
85
+ { kind: "invoice_overdue", id: "i1", invoiceNumber: "INV-1", total: "10.00", currency: "USD", dueDate: null, projectId: null, projectName: null },
86
+ { kind: "invoice_due", id: "i2", invoiceNumber: "INV-2", total: "10.00", currency: "USD", dueDate: null, projectId: null, projectName: null },
87
+ { kind: "document_to_sign", id: "d1", title: "Agreement", token: "tok", sentAt: "2026-09-01T00:00:00Z", expiresAt: null },
88
+ {
89
+ kind: "approval_requested",
90
+ id: "a1",
91
+ milestoneId: "m1",
92
+ milestoneName: "Design",
93
+ projectId: "p1",
94
+ projectName: "Site",
95
+ requestedAt: "2026-09-01T00:00:00Z",
96
+ note: null,
97
+ },
98
+ ],
99
+ }),
100
+ "home",
101
+ );
102
+ expect(nav.badges).toEqual({ invoices: 2, documents: 1, projects: 1 });
103
+ });
104
+
105
+ it("draws Messages beside Home once the person may message, and counts its unread messages", () => {
106
+ const nav = buildPortalNav(
107
+ summary({
108
+ sections: { ...empty, messages: 1 },
109
+ attention: [
110
+ { kind: "message_unread", id: "thread-1", unreadCount: 3, lastMessageAt: "2026-10-01T09:00:00Z", preview: "Hi" },
111
+ ],
112
+ }),
113
+ "home",
114
+ );
115
+ expect(keys(nav).slice(0, 3)).toEqual(["home", "messages", "activity"]);
116
+ expect(nav.badges.messages).toBe(3);
117
+ });
118
+
119
+ it("draws Files with a client's work once they have any", () => {
120
+ const nav = buildPortalNav(summary({ sections: { ...empty, files: 2 } }), "home");
121
+ expect(nav.groups.find((g) => g.group === "work")?.items.map((i) => i.key)).toEqual(["files"]);
122
+ });
123
+ });
@@ -0,0 +1,13 @@
1
+ // The client portal (`/i/account/*`): one frame, a home that leads with what
2
+ // needs the person, and a page per section.
3
+ export { PortalShell, type PortalShellProps } from "./PortalShell";
4
+ export { PortalHome } from "./PortalHome";
5
+ export { PortalActivity } from "./PortalActivity";
6
+ export { PortalProjects, PortalProject, PortalTask } from "./PortalProjects";
7
+ export { PortalInvoices, PortalDocuments } from "./PortalBilling";
8
+ export { PortalLink, usePortal, type PortalContextValue } from "./shared";
9
+ export { PORTAL_ITEMS, buildPortalNav, type PortalSection, type PortalGroup, type PortalNavItem } from "./portalNav";
10
+ export { PortalAccountSection, type PortalAccountSectionKey } from "./PortalAccountSection";
11
+ export { PortalFiles } from "./PortalFiles";
12
+ export { PortalPreviewBanner } from "./PortalPreviewBanner";
13
+ export { PortalMessages } from "./PortalMessages";