@tribe-nest/forge 3.60.0 → 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/package.json +2 -1
  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 +17 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/types/models.ts +11 -0
  37. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  38. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  39. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  40. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  41. package/src/ui/analytics/useMetaEvents.ts +78 -0
  42. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  43. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  44. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  45. package/src/ui/headless/_tests/communityHarness.tsx +10 -3
  46. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  47. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  48. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  49. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  50. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  51. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  52. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  53. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  54. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  55. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  56. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  57. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  58. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  59. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  60. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  61. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  62. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  63. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  64. package/src/ui/headless/communities/index.ts +41 -0
  65. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  66. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  67. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  68. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  69. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  70. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  71. package/src/ui/headless/community/communityPoll.ts +297 -0
  72. package/src/ui/headless/community/countries.ts +123 -0
  73. package/src/ui/headless/community/index.ts +81 -1
  74. package/src/ui/headless/community/linkPreview.ts +71 -0
  75. package/src/ui/headless/community/richLexical.ts +27 -2
  76. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  77. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  78. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  79. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  80. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  81. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  82. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  83. package/src/ui/headless/downloads/index.ts +8 -0
  84. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  85. package/src/ui/headless/index.ts +13 -0
  86. package/src/ui/headless/memberHome/index.ts +10 -0
  87. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  88. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  89. package/src/ui/headless/portal/index.ts +57 -0
  90. package/src/ui/headless/portal/usePortal.ts +274 -0
  91. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  92. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  93. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  94. package/src/ui/headless/siteApp/appBar.ts +102 -0
  95. package/src/ui/headless/siteApp/appHome.ts +344 -0
  96. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  97. package/src/ui/headless/siteApp/index.ts +58 -0
  98. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  99. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  100. package/src/ui/headless/work/index.ts +2 -0
  101. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  102. package/src/ui/index.ts +11 -0
  103. package/src/ui/styled/AccountDashboard.tsx +712 -352
  104. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  105. package/src/ui/styled/ChatRoom.tsx +126 -16
  106. package/src/ui/styled/Checkout.tsx +8 -0
  107. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  108. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  109. package/src/ui/styled/CoachingDetail.tsx +2 -0
  110. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  111. package/src/ui/styled/CourseDetail.tsx +2 -0
  112. package/src/ui/styled/DownloadPage.tsx +263 -0
  113. package/src/ui/styled/EventConfirmation.tsx +10 -0
  114. package/src/ui/styled/EventDetail.tsx +10 -0
  115. package/src/ui/styled/FilmLibrary.tsx +22 -9
  116. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  117. package/src/ui/styled/ProductDetail.tsx +12 -0
  118. package/src/ui/styled/PwaRegistration.tsx +5 -1
  119. package/src/ui/styled/SignupForm.tsx +18 -3
  120. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  121. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  122. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  123. package/src/ui/styled/app/AppBar.tsx +298 -0
  124. package/src/ui/styled/app/AppHome.tsx +1139 -0
  125. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  126. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  127. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  128. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  129. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  130. package/src/ui/styled/app/index.ts +7 -0
  131. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  132. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  133. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  134. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  135. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  136. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  137. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  138. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  139. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  140. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  141. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  142. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  143. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  144. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  145. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  146. package/src/ui/styled/communities/index.ts +9 -0
  147. package/src/ui/styled/communities/shared.tsx +39 -11
  148. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  149. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  150. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  151. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  152. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  153. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  154. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  155. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  156. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  157. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  158. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  159. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  160. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  161. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  162. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  163. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  164. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  165. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  166. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  167. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  168. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  169. package/src/ui/styled/community/index.ts +37 -2
  170. package/src/ui/styled/forge-utilities.css +234 -0
  171. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  172. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  173. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  174. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  175. package/src/ui/styled/members/index.ts +8 -0
  176. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  177. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  178. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  179. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  180. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  181. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  182. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  183. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  184. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  185. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  186. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  187. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  188. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  189. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  190. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  191. package/src/ui/styled/portal/index.ts +13 -0
  192. package/src/ui/styled/portal/portalNav.ts +260 -0
  193. package/src/ui/styled/portal/shared.tsx +206 -0
  194. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  195. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  196. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  197. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  198. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  199. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  200. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  201. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  202. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  203. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  204. package/src/ui/styled/work/index.ts +1 -1
  205. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  206. package/src/utils/landing.ts +19 -6
  207. package/src/utils/metaEvents.ts +302 -0
@@ -0,0 +1,260 @@
1
+ import {
2
+ Activity,
3
+ Bell,
4
+ BookOpen,
5
+ CalendarDays,
6
+ Clapperboard,
7
+ FileSignature,
8
+ Files,
9
+ FolderKanban,
10
+ MessageSquare,
11
+ Bookmark,
12
+ Home,
13
+ Hourglass,
14
+ Receipt,
15
+ Settings,
16
+ ShoppingBag,
17
+ Ticket,
18
+ Users,
19
+ type LucideIcon,
20
+ } from "lucide-react";
21
+ import { internalPath } from "../../../runtime/pages";
22
+ import type { PortalAttentionItem, PortalSectionKey, PortalSummary } from "../../headless/portal/usePortal";
23
+
24
+ /**
25
+ * The portal's navigation model, kept apart from how it is drawn so the rail,
26
+ * the bottom bar and the "More" sheet can never disagree about what exists.
27
+ *
28
+ * Every section is a page of its own under `/i/account/*`. A section is drawn
29
+ * only when the person has something in it (the summary's count), or when it is
30
+ * the page being shown, so a link from an email never lands on a page whose
31
+ * navigation disowns it.
32
+ */
33
+
34
+ export type PortalSection =
35
+ | "home"
36
+ | "messages"
37
+ | "activity"
38
+ | "files"
39
+ | "projects"
40
+ | "bookings"
41
+ | "invoices"
42
+ | "documents"
43
+ | "memberships"
44
+ | "courses"
45
+ | "films"
46
+ | "orders"
47
+ | "tickets"
48
+ | "waitlist"
49
+ | "saved"
50
+ | "settings"
51
+ | "notifications";
52
+
53
+ export type PortalGroup = "start" | "work" | "library" | "account";
54
+
55
+ export interface PortalNavItem {
56
+ key: PortalSection;
57
+ group: PortalGroup;
58
+ /** Literal i18n key, so the translation spec can see every one of them. */
59
+ labelKey: string;
60
+ icon: LucideIcon;
61
+ href: string;
62
+ /** The summary count that decides whether it is drawn. Absent means always drawn. */
63
+ countKey?: PortalSectionKey;
64
+ }
65
+
66
+ export const PORTAL_ITEMS: readonly PortalNavItem[] = [
67
+ { key: "home", group: "start", labelKey: "forge.portal_shell.nav_home", icon: Home, href: internalPath("account") },
68
+ {
69
+ key: "messages",
70
+ group: "start",
71
+ labelKey: "forge.portal_shell.nav_messages",
72
+ icon: MessageSquare,
73
+ href: internalPath("account/messages"),
74
+ countKey: "messages",
75
+ },
76
+ {
77
+ key: "activity",
78
+ group: "start",
79
+ labelKey: "forge.portal_shell.nav_activity",
80
+ icon: Activity,
81
+ href: internalPath("account/activity"),
82
+ },
83
+ {
84
+ key: "projects",
85
+ group: "work",
86
+ labelKey: "forge.portal_shell.nav_projects",
87
+ icon: FolderKanban,
88
+ href: internalPath("account/projects"),
89
+ countKey: "projects",
90
+ },
91
+ {
92
+ key: "bookings",
93
+ group: "work",
94
+ labelKey: "forge.portal_shell.nav_bookings",
95
+ icon: CalendarDays,
96
+ href: internalPath("account/bookings"),
97
+ countKey: "bookings",
98
+ },
99
+ {
100
+ key: "invoices",
101
+ group: "work",
102
+ labelKey: "forge.portal_shell.nav_invoices",
103
+ icon: Receipt,
104
+ href: internalPath("account/invoices"),
105
+ countKey: "invoices",
106
+ },
107
+ {
108
+ key: "documents",
109
+ group: "work",
110
+ labelKey: "forge.portal_shell.nav_documents",
111
+ icon: FileSignature,
112
+ href: internalPath("account/documents"),
113
+ countKey: "documents",
114
+ },
115
+ {
116
+ key: "files",
117
+ group: "work",
118
+ labelKey: "forge.portal_shell.nav_files",
119
+ icon: Files,
120
+ href: internalPath("account/files"),
121
+ countKey: "files",
122
+ },
123
+ {
124
+ key: "memberships",
125
+ group: "library",
126
+ labelKey: "forge.portal_shell.nav_memberships",
127
+ icon: Users,
128
+ href: internalPath("account/memberships"),
129
+ countKey: "memberships",
130
+ },
131
+ {
132
+ key: "courses",
133
+ group: "library",
134
+ labelKey: "forge.portal_shell.nav_courses",
135
+ icon: BookOpen,
136
+ href: internalPath("account/courses"),
137
+ countKey: "courses",
138
+ },
139
+ {
140
+ key: "films",
141
+ group: "library",
142
+ labelKey: "forge.portal_shell.nav_films",
143
+ icon: Clapperboard,
144
+ href: internalPath("account/films"),
145
+ countKey: "films",
146
+ },
147
+ {
148
+ key: "orders",
149
+ group: "library",
150
+ labelKey: "forge.portal_shell.nav_orders",
151
+ icon: ShoppingBag,
152
+ href: internalPath("account/orders"),
153
+ countKey: "orders",
154
+ },
155
+ {
156
+ key: "tickets",
157
+ group: "library",
158
+ labelKey: "forge.portal_shell.nav_tickets",
159
+ icon: Ticket,
160
+ href: internalPath("account/tickets"),
161
+ countKey: "tickets",
162
+ },
163
+ {
164
+ key: "waitlist",
165
+ group: "library",
166
+ labelKey: "forge.portal_shell.nav_waitlist",
167
+ icon: Hourglass,
168
+ href: internalPath("account/waitlist"),
169
+ countKey: "waitlist",
170
+ },
171
+ {
172
+ key: "saved",
173
+ group: "library",
174
+ labelKey: "forge.portal_shell.nav_saved",
175
+ icon: Bookmark,
176
+ href: internalPath("account/saved"),
177
+ countKey: "saved",
178
+ },
179
+ {
180
+ key: "settings",
181
+ group: "account",
182
+ labelKey: "forge.portal_shell.nav_settings",
183
+ icon: Settings,
184
+ href: internalPath("account/settings"),
185
+ },
186
+ {
187
+ key: "notifications",
188
+ group: "account",
189
+ labelKey: "forge.portal_shell.nav_notifications",
190
+ icon: Bell,
191
+ href: internalPath("account/notifications"),
192
+ },
193
+ ];
194
+
195
+ export const GROUP_LABEL_KEYS: Record<PortalGroup, string | null> = {
196
+ start: null,
197
+ work: "forge.portal_shell.group_work",
198
+ library: "forge.portal_shell.group_library",
199
+ account: "forge.portal_shell.group_account",
200
+ };
201
+
202
+ /** Which section an attention item belongs to, for the rail's "needs you" badges. */
203
+ export function attentionSection(item: PortalAttentionItem): PortalSection {
204
+ switch (item.kind) {
205
+ case "invoice_due":
206
+ case "invoice_overdue":
207
+ return "invoices";
208
+ case "document_to_sign":
209
+ return "documents";
210
+ case "approval_requested":
211
+ return "projects";
212
+ case "membership_payment_failed":
213
+ return "memberships";
214
+ case "message_unread":
215
+ return "messages";
216
+ }
217
+ }
218
+
219
+ export interface PortalNavModel {
220
+ /** Groups in display order, each with its drawn items. Empty groups are left out. */
221
+ groups: Array<{ group: PortalGroup; items: PortalNavItem[] }>;
222
+ /** How many things in each section need the person. */
223
+ badges: Partial<Record<PortalSection, number>>;
224
+ /** The first section of whichever group leads: the bottom bar's second slot. */
225
+ primary: PortalNavItem | null;
226
+ }
227
+
228
+ /**
229
+ * Decide what is drawn and in what order.
230
+ *
231
+ * Until the summary arrives only the always-drawn items show, rather than every
232
+ * section flashing in and then vanishing. The group with the newer activity
233
+ * leads, so a client sees their work first and a fan their library.
234
+ */
235
+ export function buildPortalNav(summary: PortalSummary | undefined, active: PortalSection): PortalNavModel {
236
+ const drawn = PORTAL_ITEMS.filter((item) => {
237
+ if (!item.countKey || item.key === active) return true;
238
+ return (summary?.sections[item.countKey] ?? 0) > 0;
239
+ });
240
+
241
+ const workAt = summary?.lastActivity.work ? Date.parse(summary.lastActivity.work) : 0;
242
+ const libraryAt = summary?.lastActivity.library ? Date.parse(summary.lastActivity.library) : 0;
243
+ const order: PortalGroup[] =
244
+ workAt >= libraryAt ? ["start", "work", "library", "account"] : ["start", "library", "work", "account"];
245
+
246
+ const groups = order
247
+ .map((group) => ({ group, items: drawn.filter((item) => item.group === group) }))
248
+ .filter((g) => g.items.length > 0);
249
+
250
+ const badges: Partial<Record<PortalSection, number>> = {};
251
+ for (const item of summary?.attention ?? []) {
252
+ const section = attentionSection(item);
253
+ // An unread conversation counts its messages; everything else counts once.
254
+ const weight = item.kind === "message_unread" ? Math.max(1, item.unreadCount) : 1;
255
+ badges[section] = (badges[section] ?? 0) + weight;
256
+ }
257
+
258
+ const lead = groups.find((g) => g.group === "work" || g.group === "library");
259
+ return { groups, badges, primary: lead?.items[0] ?? null };
260
+ }
@@ -0,0 +1,206 @@
1
+ import { useContext, type AnchorHTMLAttributes, type CSSProperties, type MouseEvent, type ReactNode } from "react";
2
+ import { createSharedContext } from "../../../runtime/registry";
3
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
4
+ import { formatWorkDate, formatWorkMoney } from "../../headless/work/useWorkPortal";
5
+
6
+ /**
7
+ * What every portal screen shares: how to navigate, and how to print a date.
8
+ *
9
+ * The shell provides it once, so a section deep inside a project page can link
10
+ * to another section without each layer passing `navigate` down by hand.
11
+ */
12
+ export interface PortalContextValue {
13
+ navigate: (href: string) => void;
14
+ formatDate: (value: string | number | Date) => string;
15
+ }
16
+
17
+ export const defaultPortalDate = (value: string | number | Date) =>
18
+ formatWorkDate(typeof value === "string" ? value : new Date(value).toISOString());
19
+
20
+ export const PortalContext = createSharedContext<PortalContextValue>("portal", {
21
+ navigate: (href) => {
22
+ if (typeof window !== "undefined") window.location.assign(href);
23
+ },
24
+ formatDate: defaultPortalDate,
25
+ });
26
+
27
+ export const usePortal = () => useContext(PortalContext);
28
+
29
+ /** Money in the record's OWN currency. An invoice is owed in what it says, never converted. */
30
+ export const portalMoney = (amount: string | number, currency: string) => formatWorkMoney(Number(amount), currency);
31
+
32
+ /**
33
+ * A link between portal screens.
34
+ *
35
+ * A real `<a href>`, so a new tab and the status bar behave. A plain left-click
36
+ * becomes an in-app navigation through the host's router instead of a reload.
37
+ */
38
+ export function PortalLink({
39
+ href,
40
+ children,
41
+ onClick,
42
+ ...rest
43
+ }: { href: string; children: ReactNode } & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href">) {
44
+ const { navigate } = usePortal();
45
+ return (
46
+ <a
47
+ href={href}
48
+ onClick={(event: MouseEvent<HTMLAnchorElement>) => {
49
+ onClick?.(event);
50
+ if (event.defaultPrevented) return;
51
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
52
+ event.preventDefault();
53
+ navigate(href);
54
+ }}
55
+ {...rest}
56
+ >
57
+ {children}
58
+ </a>
59
+ );
60
+ }
61
+
62
+ /** A screen's title, and the one sentence of state under it when there is one. */
63
+ export function PortalHeading({ title, lead, actions }: { title: ReactNode; lead?: ReactNode; actions?: ReactNode }) {
64
+ const t = useThemeTokens();
65
+ return (
66
+ <header className="fg:mb-6 fg:flex fg:flex-wrap fg:items-end fg:justify-between fg:gap-3">
67
+ <div className="fg:min-w-0">
68
+ <h1
69
+ style={{
70
+ margin: 0,
71
+ fontFamily: t.headingFontFamily,
72
+ fontSize: "clamp(26px, 4vw, 32px)",
73
+ lineHeight: 1.15,
74
+ fontWeight: 600,
75
+ letterSpacing: "-0.01em",
76
+ color: t.text,
77
+ }}
78
+ >
79
+ {title}
80
+ </h1>
81
+ {lead ? <p style={{ margin: "8px 0 0", color: t.muted, fontSize: 15, lineHeight: 1.5 }}>{lead}</p> : null}
82
+ </div>
83
+ {actions ? <div className="fg:flex fg:gap-2">{actions}</div> : null}
84
+ </header>
85
+ );
86
+ }
87
+
88
+ /** A titled block on a screen. The title is a real heading, so the page outline reads. */
89
+ export function PortalBlock({
90
+ title,
91
+ aside,
92
+ children,
93
+ id,
94
+ }: {
95
+ title: ReactNode;
96
+ aside?: ReactNode;
97
+ children: ReactNode;
98
+ id?: string;
99
+ }) {
100
+ const t = useThemeTokens();
101
+ return (
102
+ <section id={id} className="fg:mt-10 fg:first:mt-0">
103
+ <div className="fg:mb-3 fg:flex fg:items-baseline fg:justify-between fg:gap-3">
104
+ <h2 style={{ margin: 0, fontFamily: t.headingFontFamily, fontSize: 18, fontWeight: 600, color: t.text }}>
105
+ {title}
106
+ </h2>
107
+ {aside ? <div style={{ fontSize: 14 }}>{aside}</div> : null}
108
+ </div>
109
+ {children}
110
+ </section>
111
+ );
112
+ }
113
+
114
+ /** Rows divided by hairlines, with no box around each one. */
115
+ export function PortalList({ children, style }: { children: ReactNode; style?: CSSProperties }) {
116
+ const t = useThemeTokens();
117
+ return (
118
+ <ul
119
+ style={{
120
+ listStyle: "none",
121
+ margin: 0,
122
+ padding: 0,
123
+ borderTop: `1px solid ${t.border}`,
124
+ ...style,
125
+ }}
126
+ >
127
+ {children}
128
+ </ul>
129
+ );
130
+ }
131
+
132
+ export function PortalRow({ children, testId }: { children: ReactNode; testId?: string }) {
133
+ const t = useThemeTokens();
134
+ return (
135
+ <li
136
+ data-testid={testId}
137
+ className="fg:flex fg:flex-wrap fg:items-center fg:gap-x-4 fg:gap-y-2 fg:py-4"
138
+ style={{ borderBottom: `1px solid ${t.border}` }}
139
+ >
140
+ {children}
141
+ </li>
142
+ );
143
+ }
144
+
145
+ /** An empty screen says what will appear here and, where there is one, what to do. */
146
+ export function PortalEmpty({ title, body, action }: { title: string; body?: string; action?: ReactNode }) {
147
+ const t = useThemeTokens();
148
+ return (
149
+ <div
150
+ data-testid="portal-empty"
151
+ style={{
152
+ padding: "40px 24px",
153
+ border: `1px dashed ${t.border}`,
154
+ borderRadius: t.cornerRadius,
155
+ textAlign: "center",
156
+ }}
157
+ >
158
+ <p style={{ margin: 0, fontWeight: 600, color: t.text }}>{title}</p>
159
+ {body ? <p style={{ margin: "6px 0 0", color: t.muted, fontSize: 14 }}>{body}</p> : null}
160
+ {action ? <div style={{ marginTop: 16 }}>{action}</div> : null}
161
+ </div>
162
+ );
163
+ }
164
+
165
+ /** The one filled button style, in the theme's primary. */
166
+ export function usePortalButtons() {
167
+ const t = useThemeTokens();
168
+ const base: CSSProperties = {
169
+ display: "inline-flex",
170
+ alignItems: "center",
171
+ justifyContent: "center",
172
+ gap: 6,
173
+ minHeight: 38,
174
+ padding: "8px 16px",
175
+ borderRadius: t.cornerRadius,
176
+ fontFamily: t.fontFamily,
177
+ fontSize: 14,
178
+ fontWeight: 600,
179
+ cursor: "pointer",
180
+ textDecoration: "none",
181
+ whiteSpace: "nowrap",
182
+ };
183
+ return {
184
+ primary: { ...base, background: t.primary, color: t.textPrimary, border: `1px solid ${t.primary}` } as CSSProperties,
185
+ quiet: { ...base, background: "transparent", color: t.text, border: `1px solid ${t.border}` } as CSSProperties,
186
+ text: { ...base, minHeight: 0, padding: 0, background: "transparent", color: t.primary, border: "none" } as CSSProperties,
187
+ };
188
+ }
189
+
190
+ /** A thin progress line. Theme primary on the theme border. */
191
+ export function PortalProgress({ done, total, label }: { done: number; total: number; label: string }) {
192
+ const t = useThemeTokens();
193
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
194
+ return (
195
+ <div
196
+ role="progressbar"
197
+ aria-label={label}
198
+ aria-valuemin={0}
199
+ aria-valuemax={100}
200
+ aria-valuenow={pct}
201
+ style={{ height: 4, borderRadius: 2, background: t.border, overflow: "hidden" }}
202
+ >
203
+ <div style={{ width: `${pct}%`, height: "100%", background: t.primary }} />
204
+ </div>
205
+ );
206
+ }