@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,397 @@
1
+ import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
2
+ import { LogOut, Menu } from "lucide-react";
3
+ import { usePublicAuth } from "../../../contexts/PublicAuthContext";
4
+ import { useForgeT } from "../../../i18n";
5
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
6
+ import { usePortalSummary } from "../../headless/portal/usePortal";
7
+ import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "../../headless/dialog";
8
+ import { Loading } from "../Loading";
9
+ import {
10
+ GROUP_LABEL_KEYS,
11
+ PORTAL_ITEMS,
12
+ buildPortalNav,
13
+ type PortalNavItem,
14
+ type PortalSection,
15
+ } from "./portalNav";
16
+ import { PortalContext, defaultPortalDate, type PortalContextValue } from "./shared";
17
+ import { PortalPreviewBanner } from "./PortalPreviewBanner";
18
+ import { AppHomeMark } from "../app/AppHomeMark";
19
+
20
+ /** Tailwind's `lg`: the rail replaces the bottom bar from here up. */
21
+ const BAR_BREAKPOINT = 1024;
22
+ /** The bar's height above the safe area, matching `BarButton`'s min height. */
23
+ const BAR_HEIGHT = 56;
24
+ /**
25
+ * The "More" sheet is modal, so it covers everything pinned to the screen,
26
+ * including the site's chat bubble (`AiAgentWidget` defaults to 9999).
27
+ */
28
+ const SHEET_Z = 10000;
29
+
30
+ export interface PortalShellProps {
31
+ /** The section being shown. Drives the active item and keeps it drawn when empty. */
32
+ section: PortalSection;
33
+ /** SPA navigation. Pass the host router's; defaults to a full page visit. */
34
+ navigate?: (href: string) => void;
35
+ /** Short-date formatter. Defaults to Forge's work-portal date format. */
36
+ formatDate?: (value: string | number | Date) => string;
37
+ children: ReactNode;
38
+ }
39
+
40
+ /**
41
+ * The client portal's frame (`/i/account/*`).
42
+ *
43
+ * A rail on wide screens, grouped into "Your work", "Your library" and
44
+ * "Account". On a phone, a bottom bar holds Home, the person's main section,
45
+ * Activity and a "More" sheet with everything else, because a rail would eat
46
+ * the screen and a scrolling tab strip hides most of what is there.
47
+ *
48
+ * Sections the person has nothing in are not drawn (see `buildPortalNav`). A
49
+ * section with something waiting on them carries a count.
50
+ *
51
+ * The portal's Home is its own overview. The app's Home (the creator's start
52
+ * page, or `/i/app`) is the mark with the app's name, at the top of the rail
53
+ * and above the page on a phone.
54
+ */
55
+ export function PortalShell({ section, navigate, formatDate, children }: PortalShellProps) {
56
+ const t = useThemeTokens();
57
+ const tr = useForgeT();
58
+ const { user, logout } = usePublicAuth();
59
+ const { data: summary } = usePortalSummary();
60
+ const [moreOpen, setMoreOpen] = useState(false);
61
+
62
+ const go = useMemo(
63
+ () => navigate ?? ((href: string) => typeof window !== "undefined" && window.location.assign(href)),
64
+ [navigate],
65
+ );
66
+ const ctx = useMemo<PortalContextValue>(
67
+ () => ({
68
+ navigate: (href) => {
69
+ setMoreOpen(false);
70
+ go(href);
71
+ },
72
+ formatDate: formatDate ?? defaultPortalDate,
73
+ }),
74
+ [go, formatDate],
75
+ );
76
+
77
+ const nav = buildPortalNav(summary, section);
78
+
79
+ // Tell anything else pinned to the bottom of the screen (the site's chat
80
+ // bubble) how tall the bar is, so it sits above it rather than on "More".
81
+ // Set on the root because those widgets live outside this tree.
82
+ useEffect(() => {
83
+ if (typeof window === "undefined") return;
84
+ const root = document.documentElement;
85
+ const narrow = window.matchMedia(`(max-width: ${BAR_BREAKPOINT - 1}px)`);
86
+ const apply = () =>
87
+ narrow.matches
88
+ ? root.style.setProperty("--forge-bottom-bar-height", `${BAR_HEIGHT}px`)
89
+ : root.style.removeProperty("--forge-bottom-bar-height");
90
+ apply();
91
+ narrow.addEventListener("change", apply);
92
+ return () => {
93
+ narrow.removeEventListener("change", apply);
94
+ root.style.removeProperty("--forge-bottom-bar-height");
95
+ };
96
+ }, []);
97
+
98
+ if (!user) return <Loading fullPage />;
99
+
100
+ const itemLink = (item: PortalNavItem, variant: "rail" | "sheet") => {
101
+ const active = item.key === section;
102
+ const badge = nav.badges[item.key];
103
+ const style: CSSProperties =
104
+ variant === "rail"
105
+ ? {
106
+ display: "flex",
107
+ alignItems: "center",
108
+ gap: 10,
109
+ padding: "7px 12px",
110
+ marginLeft: -12,
111
+ borderLeft: `3px solid ${active ? t.primary : "transparent"}`,
112
+ color: active ? t.text : t.muted,
113
+ fontWeight: active ? 600 : 500,
114
+ fontSize: 14,
115
+ textDecoration: "none",
116
+ }
117
+ : {
118
+ display: "flex",
119
+ alignItems: "center",
120
+ gap: 12,
121
+ padding: "12px 4px",
122
+ color: t.text,
123
+ fontWeight: active ? 600 : 500,
124
+ fontSize: 16,
125
+ textDecoration: "none",
126
+ borderBottom: `1px solid ${t.border}`,
127
+ };
128
+ const Icon = item.icon;
129
+ return (
130
+ <a
131
+ key={item.key}
132
+ href={item.href}
133
+ data-testid={`portal-nav-${item.key}`}
134
+ aria-current={active ? "page" : undefined}
135
+ onClick={(event) => {
136
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
137
+ event.preventDefault();
138
+ ctx.navigate(item.href);
139
+ }}
140
+ style={style}
141
+ >
142
+ <Icon size={variant === "rail" ? 16 : 18} aria-hidden style={{ flexShrink: 0 }} />
143
+ <span style={{ flex: 1 }}>{tr(item.labelKey)}</span>
144
+ {badge ? <Badge count={badge} label={tr("forge.portal_shell.badge_label", { count: String(badge) })} /> : null}
145
+ </a>
146
+ );
147
+ };
148
+
149
+ const groupedLinks = (variant: "rail" | "sheet") =>
150
+ nav.groups.map(({ group, items }) => {
151
+ const labelKey = GROUP_LABEL_KEYS[group];
152
+ return (
153
+ <div key={group} style={{ marginTop: group === "start" ? 0 : 22 }}>
154
+ {labelKey ? (
155
+ <p style={{ margin: "0 0 6px", fontSize: 13, color: t.muted, fontWeight: 500 }}>{tr(labelKey)}</p>
156
+ ) : null}
157
+ {items.map((item) => itemLink(item, variant))}
158
+ </div>
159
+ );
160
+ });
161
+
162
+ const logOut = (
163
+ <button
164
+ type="button"
165
+ onClick={() => logout()}
166
+ className="fg:flex fg:items-center fg:gap-2.5"
167
+ style={{
168
+ marginTop: 22,
169
+ padding: "7px 0",
170
+ background: "transparent",
171
+ border: "none",
172
+ color: t.muted,
173
+ fontSize: 14,
174
+ fontFamily: t.fontFamily,
175
+ cursor: "pointer",
176
+ }}
177
+ >
178
+ <LogOut size={16} aria-hidden /> {tr("forge.portal_shell.log_out")}
179
+ </button>
180
+ );
181
+
182
+ // The bottom bar: Home, Messages when the person may message, the lead
183
+ // section, then Activity if there is still room, and More.
184
+ const home = PORTAL_ITEMS.find((i) => i.key === "home")!;
185
+ const activity = PORTAL_ITEMS.find((i) => i.key === "activity")!;
186
+ const messages = nav.groups.flatMap((g) => g.items).find((i) => i.key === "messages") ?? null;
187
+ const barItems = [home, messages, nav.primary, messages ? null : activity].filter(
188
+ (item): item is (typeof PORTAL_ITEMS)[number] => !!item,
189
+ );
190
+ const moreBadge = Object.entries(nav.badges)
191
+ .filter(([key]) => !barItems.some((i) => i.key === key))
192
+ .reduce((sum, [, n]) => sum + (n ?? 0), 0);
193
+
194
+ return (
195
+ <PortalContext.Provider value={ctx}>
196
+ <div
197
+ data-testid="portal-shell"
198
+ className="fg:flex fg:w-full fg:flex-col fg:lg:flex-row fg:lg:items-start fg:lg:gap-12"
199
+ style={{ color: t.text, fontFamily: t.fontFamily, paddingInline: 16 }}
200
+ >
201
+ <nav
202
+ aria-label={tr("forge.portal_shell.nav_label")}
203
+ className="fg:hidden fg:lg:block fg:lg:sticky fg:lg:top-6 fg:w-52 fg:shrink-0"
204
+ >
205
+ {/* The way back to the app's Home, which is not the portal's own Home. */}
206
+ <div style={{ marginBottom: 14 }}>
207
+ <AppHomeMark go={ctx.navigate} size={32} testId="portal-app-home" withName />
208
+ </div>
209
+ <p
210
+ style={{ margin: "0 0 18px", fontSize: 13, color: t.muted, overflow: "hidden", textOverflow: "ellipsis" }}
211
+ title={user.email}
212
+ >
213
+ {user.email}
214
+ </p>
215
+ {groupedLinks("rail")}
216
+ {logOut}
217
+ </nav>
218
+
219
+ <main className="fg:min-w-0 fg:w-full fg:max-w-3xl fg:flex-1 fg:pb-28 fg:lg:pb-0">
220
+ {/* On a phone the rail is hidden, so the way back to the app's Home sits above the page. */}
221
+ <div className="fg:flex fg:lg:hidden" style={{ marginBottom: 12 }}>
222
+ <AppHomeMark go={ctx.navigate} size={32} testId="portal-app-home-compact" withName />
223
+ </div>
224
+ {/* A creator previewing a client: says whose portal this is, that it is
225
+ read-only and when it ends. Draws nothing in a normal session. */}
226
+ <PortalPreviewBanner />
227
+ {children}
228
+ </main>
229
+
230
+ <nav
231
+ aria-label={tr("forge.portal_shell.nav_label")}
232
+ className="fg:fixed fg:inset-x-0 fg:bottom-0 fg:z-40 fg:flex fg:lg:hidden"
233
+ style={{
234
+ background: t.background,
235
+ borderTop: `1px solid ${t.border}`,
236
+ paddingBottom: "env(safe-area-inset-bottom)",
237
+ }}
238
+ >
239
+ {barItems.map((item) => (
240
+ <BarButton
241
+ key={item.key}
242
+ icon={<item.icon size={20} aria-hidden />}
243
+ label={tr(item.labelKey)}
244
+ active={item.key === section}
245
+ badge={nav.badges[item.key]}
246
+ href={item.href}
247
+ onGo={ctx.navigate}
248
+ testId={`portal-bar-${item.key}`}
249
+ />
250
+ ))}
251
+ <BarButton
252
+ icon={<Menu size={20} aria-hidden />}
253
+ label={tr("forge.portal_shell.more")}
254
+ active={!barItems.some((i) => i.key === section)}
255
+ badge={moreBadge || undefined}
256
+ onPress={() => setMoreOpen(true)}
257
+ testId="portal-bar-more"
258
+ />
259
+ </nav>
260
+
261
+ <DialogRoot open={moreOpen} onOpenChange={setMoreOpen}>
262
+ <DialogPortal>
263
+ <DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", zIndex: SHEET_Z }} />
264
+ <DialogContent
265
+ aria-describedby={undefined}
266
+ style={{
267
+ position: "fixed",
268
+ insetInline: 0,
269
+ bottom: 0,
270
+ zIndex: SHEET_Z + 1,
271
+ maxHeight: "85dvh",
272
+ overflowY: "auto",
273
+ background: t.background,
274
+ color: t.text,
275
+ fontFamily: t.fontFamily,
276
+ borderTopLeftRadius: Math.max(t.cornerRadius, 12),
277
+ borderTopRightRadius: Math.max(t.cornerRadius, 12),
278
+ padding: "20px 20px calc(20px + env(safe-area-inset-bottom))",
279
+ }}
280
+ >
281
+ <DialogTitle style={{ margin: "0 0 12px", fontSize: 18, fontWeight: 600, fontFamily: t.headingFontFamily }}>
282
+ {tr("forge.portal_shell.more_title")}
283
+ </DialogTitle>
284
+ {groupedLinks("sheet")}
285
+ {logOut}
286
+ </DialogContent>
287
+ </DialogPortal>
288
+ </DialogRoot>
289
+ </div>
290
+ </PortalContext.Provider>
291
+ );
292
+ }
293
+
294
+ function Badge({ count, label }: { count: number; label: string }) {
295
+ const t = useThemeTokens();
296
+ return (
297
+ <span
298
+ aria-label={label}
299
+ style={{
300
+ minWidth: 20,
301
+ height: 20,
302
+ padding: "0 6px",
303
+ borderRadius: 10,
304
+ background: t.primary,
305
+ color: t.textPrimary,
306
+ fontSize: 12,
307
+ fontWeight: 700,
308
+ lineHeight: "20px",
309
+ textAlign: "center",
310
+ }}
311
+ >
312
+ {count}
313
+ </span>
314
+ );
315
+ }
316
+
317
+ function BarButton({
318
+ icon,
319
+ label,
320
+ active,
321
+ badge,
322
+ href,
323
+ onGo,
324
+ onPress,
325
+ testId,
326
+ }: {
327
+ icon: ReactNode;
328
+ label: string;
329
+ active: boolean;
330
+ badge?: number;
331
+ href?: string;
332
+ onGo?: (href: string) => void;
333
+ onPress?: () => void;
334
+ testId: string;
335
+ }) {
336
+ const t = useThemeTokens();
337
+ const style: CSSProperties = {
338
+ position: "relative",
339
+ flex: 1,
340
+ display: "flex",
341
+ flexDirection: "column",
342
+ alignItems: "center",
343
+ gap: 3,
344
+ padding: "9px 4px 8px",
345
+ minHeight: BAR_HEIGHT,
346
+ background: "transparent",
347
+ border: "none",
348
+ color: active ? t.primary : t.muted,
349
+ fontFamily: t.fontFamily,
350
+ fontSize: 11,
351
+ fontWeight: active ? 700 : 500,
352
+ textDecoration: "none",
353
+ cursor: "pointer",
354
+ };
355
+ const content = (
356
+ <>
357
+ {icon}
358
+ <span>{label}</span>
359
+ {badge ? (
360
+ <span
361
+ aria-hidden
362
+ style={{
363
+ position: "absolute",
364
+ top: 6,
365
+ left: "calc(50% + 6px)",
366
+ width: 8,
367
+ height: 8,
368
+ borderRadius: 4,
369
+ background: t.primary,
370
+ }}
371
+ />
372
+ ) : null}
373
+ </>
374
+ );
375
+ if (href && onGo) {
376
+ return (
377
+ <a
378
+ href={href}
379
+ data-testid={testId}
380
+ aria-current={active ? "page" : undefined}
381
+ onClick={(event) => {
382
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
383
+ event.preventDefault();
384
+ onGo(href);
385
+ }}
386
+ style={style}
387
+ >
388
+ {content}
389
+ </a>
390
+ );
391
+ }
392
+ return (
393
+ <button type="button" data-testid={testId} onClick={onPress} style={style}>
394
+ {content}
395
+ </button>
396
+ );
397
+ }
@@ -0,0 +1,90 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
4
+ import type { ReactNode } from "react";
5
+ import { ForgeThemeProvider } from "../../../theme/ForgeThemeProvider";
6
+ import { PortalShell } from "../PortalShell";
7
+ import { account, get, installAdapter, restoreAdapter, wrapper } from "../../../headless/_tests/communityHarness";
8
+ import { siteApp } from "../../../headless/_tests/siteAppHarness";
9
+
10
+ /**
11
+ * The Home rule inside the client portal: the portal's own Home is its
12
+ * overview, so the way back to the app's Home is the app mark, at the top of
13
+ * the rail and above the page on a phone. It leads to the creator's start page
14
+ * when they chose one and to `/i/app` otherwise.
15
+ */
16
+
17
+ // jsdom has no matchMedia; the shell asks it whether the phone bar is showing.
18
+ beforeEach(() => {
19
+ vi.stubGlobal("matchMedia", (query: string) => ({
20
+ matches: false,
21
+ media: query,
22
+ addEventListener: () => {},
23
+ removeEventListener: () => {},
24
+ }));
25
+ });
26
+ afterEach(() => {
27
+ restoreAdapter();
28
+ vi.unstubAllGlobals();
29
+ });
30
+
31
+ const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
32
+
33
+ function mount(node: ReactNode) {
34
+ const Wrapper = wrapper({ community: null, user: account() });
35
+ return render(
36
+ <Wrapper>
37
+ <ForgeThemeProvider theme={theme}>{node}</ForgeThemeProvider>
38
+ </Wrapper>,
39
+ );
40
+ }
41
+
42
+ const summary = {
43
+ sections: { projects: 1, invoices: 0, documents: 0, bookings: 0, memberships: 0, courses: 0, films: 0, orders: 0, tickets: 0, waitlist: 0, saved: 0, messages: 0, files: 0 },
44
+ lastActivity: { work: null, library: null },
45
+ attention: [],
46
+ };
47
+
48
+ const routes = (app: () => unknown) => [get("/public/websites/app", app), get("/public/portal/summary", () => summary)];
49
+
50
+ describe("PortalShell and the app's Home", () => {
51
+ it("leads both marks to the creator's own start page, named for the app", async () => {
52
+ installAdapter(routes(() => siteApp({ appName: "Northlight", startUrl: "/members", iconUrl: "https://cdn.example/icon.png" })));
53
+ const navigate = vi.fn();
54
+ mount(
55
+ <PortalShell section="home" navigate={navigate}>
56
+ <p>overview</p>
57
+ </PortalShell>,
58
+ );
59
+
60
+ const rail = await screen.findByTestId("portal-app-home");
61
+ const compact = screen.getByTestId("portal-app-home-compact");
62
+ await waitFor(() => expect(rail.getAttribute("href")).toBe("/members"));
63
+ expect(compact.getAttribute("href")).toBe("/members");
64
+ // Named for the app, so it never reads as a second "Home" beside the portal's own.
65
+ expect(rail.textContent).toBe("Northlight");
66
+ expect(compact.querySelector("img")?.getAttribute("src")).toBe("https://cdn.example/icon.png");
67
+ // The portal's own Home is still the overview.
68
+ expect(screen.getByTestId("portal-bar-home").getAttribute("href")).toBe("/i/account");
69
+
70
+ fireEvent.click(compact, { button: 0 });
71
+ expect(navigate).toHaveBeenCalledWith("/members");
72
+ });
73
+
74
+ it("falls back to /i/app, labelled Home, when the site has no app settings", async () => {
75
+ installAdapter(
76
+ routes(() => {
77
+ throw { status: 404 };
78
+ }),
79
+ );
80
+ mount(
81
+ <PortalShell section="home">
82
+ <p>overview</p>
83
+ </PortalShell>,
84
+ );
85
+ const compact = await screen.findByTestId("portal-app-home-compact");
86
+ expect(compact.getAttribute("href")).toBe("/i/app");
87
+ expect(compact.getAttribute("aria-label")).toBe("Home");
88
+ expect(screen.getByTestId("portal-app-home").getAttribute("href")).toBe("/i/app");
89
+ });
90
+ });
@@ -0,0 +1,159 @@
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 { portalFilesKey, type PortalFile, type PortalFilesPage } from "../../../headless/portal/usePortalFiles";
8
+ import { PortalFiles, formatFileSize } from "../PortalFiles";
9
+
10
+ /**
11
+ * The Files page, rendered through `react-dom/server` with the first page
12
+ * SEEDED into the query cache, so the real `usePortalFiles` (and its key)
13
+ * stays in the path.
14
+ */
15
+
16
+ const PROFILE_ID = "profile-1";
17
+ const ACCOUNT_ID = "account-1";
18
+
19
+ const member = {
20
+ id: ACCOUNT_ID,
21
+ email: "ada@example.com",
22
+ firstName: "Ada",
23
+ lastName: "Client",
24
+ kind: "fan",
25
+ status: "active",
26
+ createdAt: "2026-01-01T00:00:00.000Z",
27
+ updatedAt: "2026-01-01T00:00:00.000Z",
28
+ membership: null,
29
+ };
30
+
31
+ const FILES: PortalFile[] = [
32
+ {
33
+ id: "att-1",
34
+ source: "task",
35
+ title: "cover-art.png",
36
+ filename: "cover-art.png",
37
+ mimeType: "image/png",
38
+ size: 1536,
39
+ createdAt: "2026-09-30T10:00:00.000Z",
40
+ projectId: "proj-1",
41
+ projectName: "Album Mix",
42
+ open: { kind: "task_attachment", projectId: "proj-1", taskId: "task-1", url: "https://cdn.test/cover-art.png" },
43
+ },
44
+ {
45
+ id: "doc-1",
46
+ source: "project",
47
+ title: "Mix Notes",
48
+ filename: "mix-notes.pdf",
49
+ mimeType: "application/pdf",
50
+ size: 2 * 1024 * 1024,
51
+ createdAt: "2026-09-29T10:00:00.000Z",
52
+ projectId: "proj-1",
53
+ projectName: "Album Mix",
54
+ open: { kind: "project_document", projectId: "proj-1", documentId: "doc-1" },
55
+ },
56
+ {
57
+ id: "rec-1",
58
+ source: "document",
59
+ title: "Engagement Letter",
60
+ filename: "engagement-letter-signed.pdf",
61
+ mimeType: "application/pdf",
62
+ size: null,
63
+ createdAt: "2026-09-28T10:00:00.000Z",
64
+ projectId: null,
65
+ projectName: null,
66
+ open: { kind: "signed_document", token: "tok-abc" },
67
+ },
68
+ {
69
+ id: "inv-1",
70
+ source: "invoice",
71
+ title: "INV-1042",
72
+ filename: "invoice-INV-1042.pdf",
73
+ mimeType: "application/pdf",
74
+ size: null,
75
+ createdAt: "2026-09-27T10:00:00.000Z",
76
+ projectId: null,
77
+ projectName: null,
78
+ open: { kind: "invoice_pdf", invoiceId: "inv-1" },
79
+ },
80
+ ];
81
+
82
+ function render(page: PortalFilesPage | null): string {
83
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
84
+ if (page) {
85
+ queryClient.setQueryData(portalFilesKey(PROFILE_ID, ACCOUNT_ID, "all", 30), { pages: [page], pageParams: [1] });
86
+ }
87
+ return renderToStaticMarkup(
88
+ <QueryClientProvider client={queryClient}>
89
+ <ForgeClientProvider apiUrl="http://api.test/" profileId={PROFILE_ID}>
90
+ <PublicAuthContext.Provider
91
+ value={{ user: member, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}
92
+ >
93
+ <ForgeThemeProvider
94
+ theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
95
+ >
96
+ <PortalFiles />
97
+ </ForgeThemeProvider>
98
+ </PublicAuthContext.Provider>
99
+ </ForgeClientProvider>
100
+ </QueryClientProvider>,
101
+ );
102
+ }
103
+
104
+ const rows = (html: string) => html.split('data-testid="portal-file"').slice(1);
105
+
106
+ describe("PortalFiles", () => {
107
+ it("renders one row per file, newest first, with its meta and the right action", () => {
108
+ const html = render({ data: FILES, total: 4, page: 1, pageSize: 30 });
109
+ const [task, project, signed, invoice] = rows(html);
110
+ expect(rows(html)).toHaveLength(4);
111
+
112
+ expect(task).toContain("cover-art.png");
113
+ expect(task).toContain("Album Mix");
114
+ expect(task).toContain("1.5 KB");
115
+ expect(task).toContain('href="https://cdn.test/cover-art.png"');
116
+ expect(task).toContain(">Open<");
117
+
118
+ // A shared project file is a button: its address is minted on the click.
119
+ expect(project).toContain("Mix Notes");
120
+ expect(project).toContain("2.0 MB");
121
+ expect(project).toMatch(/<button[^>]*data-testid="portal-file-action"/);
122
+ expect(project).not.toContain("href=");
123
+
124
+ expect(signed).toContain("Engagement Letter");
125
+ expect(signed).toContain('href="http://api.test/public/documents/tok-abc/download"');
126
+ expect(signed).toContain(">Download<");
127
+
128
+ expect(invoice).toContain("Invoice INV-1042");
129
+ expect(invoice).toContain('href="http://api.test/public/invoices/inv-1/pdf"');
130
+ });
131
+
132
+ it("draws the filter tabs, with All selected", () => {
133
+ const html = render({ data: FILES, total: 4, page: 1, pageSize: 30 });
134
+ for (const key of ["all", "project", "task", "document", "invoice"]) {
135
+ expect(html).toContain(`data-testid="portal-files-filter-${key}"`);
136
+ }
137
+ expect(html).toMatch(/aria-selected="true"[^>]*data-testid="portal-files-filter-all"/);
138
+ });
139
+
140
+ it("offers more when the server has more", () => {
141
+ expect(render({ data: FILES, total: 40, page: 1, pageSize: 30 })).toContain('data-testid="portal-files-more"');
142
+ expect(render({ data: FILES, total: 4, page: 1, pageSize: 30 })).not.toContain('data-testid="portal-files-more"');
143
+ });
144
+
145
+ it("says what will appear when there are no files at all, without tabs", () => {
146
+ const html = render({ data: [], total: 0, page: 1, pageSize: 30 });
147
+ expect(html).toContain('data-testid="portal-empty"');
148
+ expect(html).toContain("No files yet");
149
+ expect(html).not.toContain("portal-files-filter-all");
150
+ });
151
+
152
+ it("formats sizes", () => {
153
+ expect(formatFileSize(null)).toBeNull();
154
+ expect(formatFileSize(0)).toBeNull();
155
+ expect(formatFileSize(512)).toBe("512 B");
156
+ expect(formatFileSize(1536)).toBe("1.5 KB");
157
+ expect(formatFileSize(50 * 1024 * 1024)).toBe("50 MB");
158
+ });
159
+ });