@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,401 @@
1
+ import { useForgeLocale, useForgeT, type ForgeT } from "../../../i18n";
2
+ import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
+ import { internalPath } from "../../../runtime/pages";
4
+ import { useMyBookings } from "../../../data/queries/useMyBookings";
5
+ import { useMyTickets } from "../../../data/queries/useMyTickets";
6
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
7
+ import { bookingCallWindow } from "../../media/bookingWindow";
8
+ import {
9
+ usePortalActivity,
10
+ usePortalSummary,
11
+ type PortalAttentionItem,
12
+ } from "../../headless/portal/usePortal";
13
+ import { useWorkPortalProjects } from "../../headless/work/useWorkPortal";
14
+ import { Loading } from "../Loading";
15
+ import { ActivityRows } from "./PortalActivity";
16
+ import {
17
+ PortalBlock,
18
+ PortalHeading,
19
+ PortalLink,
20
+ PortalList,
21
+ PortalProgress,
22
+ portalMoney,
23
+ usePortal,
24
+ usePortalButtons,
25
+ } from "./shared";
26
+
27
+ /** How far ahead "Coming up" looks. Two weeks is what a person plans around. */
28
+ const AGENDA_DAYS = 14;
29
+ const AGENDA_LIMIT = 5;
30
+
31
+ /**
32
+ * The portal's home (`/i/account`).
33
+ *
34
+ * It answers one question before any other: what needs me? Everything the
35
+ * person owes or has to decide sits in one ranked list, each item one click from
36
+ * the exact thing. Below it: what is coming up, how their projects stand, and
37
+ * what changed lately. A section with nothing to say is not drawn.
38
+ */
39
+ export function PortalHome() {
40
+ const tr = useForgeT();
41
+ const { user } = usePublicAuth();
42
+ const { data: summary, isLoading } = usePortalSummary();
43
+
44
+ if (isLoading || !summary) return <Loading />;
45
+
46
+ const attention = summary.attention;
47
+ const hour = new Date().getHours();
48
+ const name = user?.firstName?.trim();
49
+ const part = hour < 12 ? "morning" : hour < 18 ? "afternoon" : "evening";
50
+ // Literal keys, so the translation spec can see every one of them.
51
+ const greeting = name
52
+ ? {
53
+ morning: tr("forge.portal_home.greeting_morning_name", { name }),
54
+ afternoon: tr("forge.portal_home.greeting_afternoon_name", { name }),
55
+ evening: tr("forge.portal_home.greeting_evening_name", { name }),
56
+ }[part]
57
+ : {
58
+ morning: tr("forge.portal_home.greeting_morning"),
59
+ afternoon: tr("forge.portal_home.greeting_afternoon"),
60
+ evening: tr("forge.portal_home.greeting_evening"),
61
+ }[part];
62
+
63
+ return (
64
+ <div data-testid="portal-home">
65
+ <PortalHeading
66
+ title={greeting}
67
+ lead={
68
+ attention.length === 0
69
+ ? tr("forge.portal_home.lead_clear")
70
+ : attention.length === 1
71
+ ? tr("forge.portal_home.lead_one")
72
+ : tr("forge.portal_home.lead_many", { count: attention.length })
73
+ }
74
+ />
75
+
76
+ {attention.length > 0 ? <NeedsYou items={attention} /> : null}
77
+
78
+ <ComingUp hasBookings={summary.sections.bookings > 0} hasTickets={summary.sections.tickets > 0} />
79
+
80
+ {summary.sections.projects > 0 ? <ProjectsGlance /> : null}
81
+
82
+ <RecentActivity />
83
+ </div>
84
+ );
85
+ }
86
+
87
+ // ---- Needs you --------------------------------------------------------------
88
+
89
+ function attentionCopy(
90
+ item: PortalAttentionItem,
91
+ tr: ForgeT,
92
+ formatDate: (v: string) => string,
93
+ ): { title: string; meta: string[]; action: string; href: string } {
94
+ switch (item.kind) {
95
+ case "invoice_overdue":
96
+ case "invoice_due": {
97
+ const amount = portalMoney(item.total, item.currency);
98
+ const when = item.dueDate
99
+ ? item.kind === "invoice_overdue"
100
+ ? tr("forge.portal_home.overdue_since", { date: formatDate(item.dueDate) })
101
+ : tr("forge.portal_home.due_on", { date: formatDate(item.dueDate) })
102
+ : null;
103
+ return {
104
+ title: tr("forge.portal_home.pay_invoice", { number: item.invoiceNumber }),
105
+ meta: [amount, ...(when ? [when] : []), ...(item.projectName ? [item.projectName] : [])],
106
+ action: tr("forge.portal_home.pay"),
107
+ href: internalPath("invoice/$invoiceId", { invoiceId: item.id }),
108
+ };
109
+ }
110
+ case "approval_requested":
111
+ return {
112
+ title: tr("forge.portal_home.review_milestone", { milestone: item.milestoneName }),
113
+ meta: [item.projectName, ...(item.note ? [item.note] : [])],
114
+ action: tr("forge.portal_home.review"),
115
+ href: `${internalPath("account/projects/$projectId", { projectId: item.projectId })}#approvals`,
116
+ };
117
+ case "document_to_sign":
118
+ return {
119
+ title: tr("forge.portal_home.sign_document", { title: item.title }),
120
+ meta: [
121
+ tr("forge.portal_home.sent_on", { date: formatDate(item.sentAt) }),
122
+ ...(item.expiresAt ? [tr("forge.portal_home.expires_on", { date: formatDate(item.expiresAt) })] : []),
123
+ ],
124
+ action: tr("forge.portal_home.review_and_sign"),
125
+ href: internalPath("documents/$token", { token: item.token }),
126
+ };
127
+ case "message_unread":
128
+ return {
129
+ title:
130
+ item.unreadCount === 1
131
+ ? tr("forge.portal_home.new_message_one")
132
+ : tr("forge.portal_home.new_message_many", { count: item.unreadCount }),
133
+ meta: item.preview ? [item.preview] : [],
134
+ action: tr("forge.portal_home.reply"),
135
+ href: internalPath("account/messages"),
136
+ };
137
+ case "membership_payment_failed":
138
+ return {
139
+ title: tr("forge.portal_home.update_card", { community: item.communityName }),
140
+ meta: [tr("forge.portal_home.payment_failed")],
141
+ action: tr("forge.portal_home.update"),
142
+ href: internalPath("communities/$slug/home", { slug: item.communitySlug }),
143
+ };
144
+ }
145
+ }
146
+
147
+ /**
148
+ * The one loud thing on the page.
149
+ *
150
+ * A single ranked list rather than a row of counters: "Pay invoice INV-1042"
151
+ * is something a person can act on, "2 invoices" is something they have to go
152
+ * and look up. The theme's primary edges the list and fills each action; it is
153
+ * used nowhere else on Home.
154
+ */
155
+ function NeedsYou({ items }: { items: PortalAttentionItem[] }) {
156
+ const t = useThemeTokens();
157
+ const tr = useForgeT();
158
+ const { formatDate } = usePortal();
159
+ const buttons = usePortalButtons();
160
+
161
+ return (
162
+ <section aria-labelledby="portal-needs-you" data-testid="portal-needs-you" style={{ marginBottom: 40 }}>
163
+ <h2 id="portal-needs-you" style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" }}>
164
+ {tr("forge.portal_home.needs_you")}
165
+ </h2>
166
+ <ol
167
+ style={{
168
+ listStyle: "none",
169
+ margin: 0,
170
+ padding: 0,
171
+ borderLeft: `3px solid ${t.primary}`,
172
+ background: t.surface,
173
+ borderRadius: `0 ${t.cornerRadius}px ${t.cornerRadius}px 0`,
174
+ }}
175
+ >
176
+ {items.map((item, index) => {
177
+ const copy = attentionCopy(item, tr, formatDate);
178
+ const overdue = item.kind === "invoice_overdue";
179
+ return (
180
+ <li
181
+ key={`${item.kind}:${item.id}`}
182
+ data-testid={`portal-attention-${item.kind}`}
183
+ className="fg:flex fg:flex-col fg:gap-3 fg:sm:flex-row fg:sm:items-center"
184
+ style={{
185
+ padding: "16px 20px",
186
+ borderTop: index === 0 ? "none" : `1px solid ${t.border}`,
187
+ }}
188
+ >
189
+ <div className="fg:min-w-0 fg:flex-1">
190
+ <p style={{ margin: 0, fontSize: 16, fontWeight: 600, lineHeight: 1.35, color: t.text }}>{copy.title}</p>
191
+ <p
192
+ className="fg:flex fg:flex-wrap fg:gap-x-3"
193
+ style={{ margin: "4px 0 0", fontSize: 14, color: t.muted }}
194
+ >
195
+ {copy.meta.map((part, i) => (
196
+ <span
197
+ key={i}
198
+ style={overdue && i === 1 ? { color: t.text, fontWeight: 600 } : undefined}
199
+ >
200
+ {part}
201
+ </span>
202
+ ))}
203
+ </p>
204
+ </div>
205
+ <PortalLink href={copy.href} style={buttons.primary} data-testid="portal-attention-action">
206
+ {copy.action}
207
+ </PortalLink>
208
+ </li>
209
+ );
210
+ })}
211
+ </ol>
212
+ </section>
213
+ );
214
+ }
215
+
216
+ // ---- Coming up --------------------------------------------------------------
217
+
218
+ type AgendaEntry = {
219
+ key: string;
220
+ at: Date;
221
+ title: string;
222
+ kindLabel: string;
223
+ action: { label: string; href: string; primary: boolean };
224
+ };
225
+
226
+ /**
227
+ * Sessions and shows in the next two weeks, on one dated agenda.
228
+ *
229
+ * A session whose call is open now gets a filled "Join" button, the one moment
230
+ * the agenda earns the primary colour.
231
+ */
232
+ function ComingUp({ hasBookings, hasTickets }: { hasBookings: boolean; hasTickets: boolean }) {
233
+ const t = useThemeTokens();
234
+ const tr = useForgeT();
235
+ const locale = useForgeLocale();
236
+ const buttons = usePortalButtons();
237
+ const { user } = usePublicAuth();
238
+ const { data: bookings } = useMyBookings(hasBookings ? user?.id : undefined, 1, 20);
239
+ const { data: tickets } = useMyTickets(hasTickets ? user?.id : undefined, 1, 20);
240
+
241
+ const now = new Date();
242
+ const horizon = now.getTime() + AGENDA_DAYS * 86400_000;
243
+ const entries: AgendaEntry[] = [];
244
+
245
+ for (const booking of hasBookings ? (bookings?.data ?? []) : []) {
246
+ if (booking.status !== "confirmed") continue;
247
+ const start = new Date(booking.sessionStartTime);
248
+ const end = new Date(booking.sessionEndTime);
249
+ if (end.getTime() < now.getTime() || start.getTime() > horizon) continue;
250
+ const window = bookingCallWindow(booking, now);
251
+ const canJoin = window.isOpen && !!booking.videoCallId;
252
+ entries.push({
253
+ key: `booking:${booking.id}`,
254
+ at: start,
255
+ title: booking.coachingProductTitle,
256
+ kindLabel: tr("forge.portal_home.kind_session"),
257
+ action: canJoin
258
+ ? {
259
+ label: tr("forge.portal_home.join"),
260
+ href: internalPath("video-calls/$callId", { callId: booking.videoCallId! }),
261
+ primary: true,
262
+ }
263
+ : { label: tr("forge.portal_home.details"), href: internalPath("account/bookings"), primary: false },
264
+ });
265
+ }
266
+
267
+ for (const ticket of hasTickets ? (tickets?.data ?? []) : []) {
268
+ if (ticket.selfCancelledAt || ticket.status === "cancelled" || ticket.status === "refunded") continue;
269
+ const start = new Date(ticket.eventDateTime);
270
+ const end = ticket.eventEndDateTime ? new Date(ticket.eventEndDateTime) : start;
271
+ if (end.getTime() < now.getTime() || start.getTime() > horizon) continue;
272
+ entries.push({
273
+ key: `ticket:${ticket.id}`,
274
+ at: start,
275
+ title: ticket.eventTitle,
276
+ kindLabel: tr("forge.portal_home.kind_event"),
277
+ action: { label: tr("forge.portal_home.view_ticket"), href: internalPath("account/tickets"), primary: false },
278
+ });
279
+ }
280
+
281
+ if (entries.length === 0) return null;
282
+ entries.sort((a, b) => a.at.getTime() - b.at.getTime());
283
+
284
+ const weekday = new Intl.DateTimeFormat(locale, { weekday: "short" });
285
+ const day = new Intl.DateTimeFormat(locale, { day: "numeric" });
286
+ const month = new Intl.DateTimeFormat(locale, { month: "short" });
287
+ const time = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit" });
288
+
289
+ return (
290
+ <PortalBlock title={tr("forge.portal_home.coming_up")}>
291
+ <PortalList>
292
+ {entries.slice(0, AGENDA_LIMIT).map((entry) => (
293
+ <li
294
+ key={entry.key}
295
+ data-testid="portal-agenda-item"
296
+ className="fg:flex fg:items-center fg:gap-4 fg:py-3"
297
+ style={{ borderBottom: `1px solid ${t.border}` }}
298
+ >
299
+ {/* The date is the row's anchor: an agenda is read down its dates. */}
300
+ <div style={{ width: 48, flexShrink: 0, textAlign: "center", lineHeight: 1.1 }} aria-hidden>
301
+ <div style={{ fontSize: 12, color: t.muted }}>{weekday.format(entry.at)}</div>
302
+ <div style={{ fontSize: 24, fontWeight: 600, fontFamily: t.headingFontFamily, color: t.text }}>
303
+ {day.format(entry.at)}
304
+ </div>
305
+ <div style={{ fontSize: 12, color: t.muted }}>{month.format(entry.at)}</div>
306
+ </div>
307
+ <div className="fg:min-w-0 fg:flex-1">
308
+ <p style={{ margin: 0, fontWeight: 600, color: t.text }}>{entry.title}</p>
309
+ <p style={{ margin: "2px 0 0", fontSize: 14, color: t.muted }}>
310
+ <time dateTime={entry.at.toISOString()}>
311
+ {weekday.format(entry.at)} {day.format(entry.at)} {month.format(entry.at)}, {time.format(entry.at)}
312
+ </time>{" "}
313
+ <span>{entry.kindLabel}</span>
314
+ </p>
315
+ </div>
316
+ <PortalLink href={entry.action.href} style={entry.action.primary ? buttons.primary : buttons.quiet}>
317
+ {entry.action.label}
318
+ </PortalLink>
319
+ </li>
320
+ ))}
321
+ </PortalList>
322
+ </PortalBlock>
323
+ );
324
+ }
325
+
326
+ // ---- Projects ---------------------------------------------------------------
327
+
328
+ function ProjectsGlance() {
329
+ const t = useThemeTokens();
330
+ const tr = useForgeT();
331
+ const { formatDate } = usePortal();
332
+ const buttons = usePortalButtons();
333
+ const { data: projects } = useWorkPortalProjects();
334
+ const active = (projects ?? []).filter((p) => p.status !== "completed" && p.status !== "archived");
335
+ if (active.length === 0) return null;
336
+
337
+ return (
338
+ <PortalBlock
339
+ title={tr("forge.portal_home.projects")}
340
+ aside={
341
+ <PortalLink href={internalPath("account/projects")} style={buttons.text}>
342
+ {tr("forge.portal_home.all_projects")}
343
+ </PortalLink>
344
+ }
345
+ >
346
+ <PortalList>
347
+ {active.slice(0, 4).map((project) => (
348
+ <li key={project.id} style={{ borderBottom: `1px solid ${t.border}` }}>
349
+ <PortalLink
350
+ href={internalPath("account/projects/$projectId", { projectId: project.id })}
351
+ data-testid="portal-home-project"
352
+ className="fg:block fg:py-4"
353
+ style={{ color: t.text, textDecoration: "none" }}
354
+ >
355
+ <span className="fg:flex fg:items-baseline fg:justify-between fg:gap-3">
356
+ <span style={{ fontWeight: 600 }}>{project.name}</span>
357
+ <span style={{ fontSize: 13, color: t.muted, whiteSpace: "nowrap" }}>
358
+ {tr("forge.portal_home.tasks_done", { done: project.doneCount, total: project.taskCount })}
359
+ </span>
360
+ </span>
361
+ <span style={{ display: "block", margin: "10px 0 6px" }}>
362
+ <PortalProgress
363
+ done={project.doneCount}
364
+ total={project.taskCount}
365
+ label={tr("forge.portal_home.progress_label", { name: project.name })}
366
+ />
367
+ </span>
368
+ {project.dueDate ? (
369
+ <span style={{ fontSize: 13, color: t.muted }}>
370
+ {tr("forge.portal_home.due_on", { date: formatDate(project.dueDate) })}
371
+ </span>
372
+ ) : null}
373
+ </PortalLink>
374
+ </li>
375
+ ))}
376
+ </PortalList>
377
+ </PortalBlock>
378
+ );
379
+ }
380
+
381
+ // ---- Recent activity --------------------------------------------------------
382
+
383
+ function RecentActivity() {
384
+ const tr = useForgeT();
385
+ const buttons = usePortalButtons();
386
+ const { data } = usePortalActivity(5);
387
+ const items = data?.pages[0]?.items ?? [];
388
+ if (items.length === 0) return null;
389
+ return (
390
+ <PortalBlock
391
+ title={tr("forge.portal_home.recent")}
392
+ aside={
393
+ <PortalLink href={internalPath("account/activity")} style={buttons.text}>
394
+ {tr("forge.portal_home.all_activity")}
395
+ </PortalLink>
396
+ }
397
+ >
398
+ <ActivityRows items={items} />
399
+ </PortalBlock>
400
+ );
401
+ }
@@ -0,0 +1,300 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Paperclip, X } from "lucide-react";
3
+ import { useForgeLocale, useForgeT } from "../../../i18n";
4
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
5
+ import { formatBytes } from "../../headless/chat/attachmentKind";
6
+ import {
7
+ portalMessagesInOrder,
8
+ useMarkPortalMessagesRead,
9
+ usePortalMessageAttachments,
10
+ usePortalMessages,
11
+ usePortalMessagesLive,
12
+ usePortalThread,
13
+ useSendPortalMessage,
14
+ type PortalMessage,
15
+ } from "../../headless/portal/usePortalMessages";
16
+ import { ChatAttachments } from "../chat/ChatAttachments";
17
+ import { Loading } from "../Loading";
18
+ import { PortalEmpty, PortalHeading, usePortalButtons } from "./shared";
19
+
20
+ /**
21
+ * Messages (`/i/account/messages`): the one conversation this person has with
22
+ * the creator. Their own messages sit on the right in the theme's primary, the
23
+ * creator's replies on the left, signed with the creator's name. Live over the
24
+ * chat socket, with a 10 second re-read while the socket is down.
25
+ *
26
+ * Someone the creator does not know as a client (no project, booking or
27
+ * invoice) and who has no conversation yet is told so, and gets no composer,
28
+ * unless the creator has opened Messages to every member.
29
+ */
30
+ export function PortalMessages() {
31
+ const tr = useForgeT();
32
+ const thread = usePortalThread();
33
+ const messages = usePortalMessages();
34
+ const markRead = useMarkPortalMessagesRead();
35
+ const threadId = thread.data?.thread?.id ?? null;
36
+ usePortalMessagesLive(threadId);
37
+
38
+ const ordered = portalMessagesInOrder(messages.data?.pages);
39
+ const newestId = ordered[ordered.length - 1]?.id;
40
+ const unread = thread.data?.thread?.unreadCount ?? 0;
41
+ const bottomRef = useRef<HTMLDivElement | null>(null);
42
+
43
+ // Reading the page is reading the replies.
44
+ useEffect(() => {
45
+ if (threadId && unread > 0) markRead.mutate();
46
+ // eslint-disable-next-line react-hooks/exhaustive-deps
47
+ }, [threadId, unread, newestId]);
48
+
49
+ useEffect(() => {
50
+ bottomRef.current?.scrollIntoView?.({ block: "end" });
51
+ }, [newestId]);
52
+
53
+ const creator = thread.data?.creator.name ?? "";
54
+ const allowed = !!thread.data?.allowed;
55
+
56
+ return (
57
+ <div data-testid="portal-messages">
58
+ <PortalHeading title={tr("forge.portal_messages.title")} />
59
+ {thread.isLoading || (threadId && messages.isLoading) ? (
60
+ <Loading />
61
+ ) : (
62
+ <>
63
+ {ordered.length === 0 ? (
64
+ allowed ? (
65
+ <PortalEmpty
66
+ title={tr("forge.portal_messages.empty_title", { creator })}
67
+ body={tr("forge.portal_messages.empty_body")}
68
+ />
69
+ ) : (
70
+ <PortalEmpty
71
+ title={tr("forge.portal_messages.closed_title")}
72
+ body={tr("forge.portal_messages.closed_body", { creator })}
73
+ />
74
+ )
75
+ ) : (
76
+ <Conversation
77
+ messages={ordered}
78
+ creator={creator}
79
+ hasEarlier={!!messages.hasNextPage}
80
+ loadingEarlier={messages.isFetchingNextPage}
81
+ onLoadEarlier={() => void messages.fetchNextPage()}
82
+ />
83
+ )}
84
+ <div ref={bottomRef} />
85
+ {allowed ? <Composer /> : null}
86
+ </>
87
+ )}
88
+ </div>
89
+ );
90
+ }
91
+
92
+ function Conversation({
93
+ messages,
94
+ creator,
95
+ hasEarlier,
96
+ loadingEarlier,
97
+ onLoadEarlier,
98
+ }: {
99
+ messages: PortalMessage[];
100
+ creator: string;
101
+ hasEarlier: boolean;
102
+ loadingEarlier: boolean;
103
+ onLoadEarlier: () => void;
104
+ }) {
105
+ const tr = useForgeT();
106
+ const buttons = usePortalButtons();
107
+ return (
108
+ <div>
109
+ {hasEarlier ? (
110
+ <div className="fg:mb-4 fg:flex fg:justify-center">
111
+ <button
112
+ type="button"
113
+ data-testid="portal-messages-earlier"
114
+ onClick={onLoadEarlier}
115
+ disabled={loadingEarlier}
116
+ style={{ ...buttons.quiet, opacity: loadingEarlier ? 0.6 : 1 }}
117
+ >
118
+ {tr("forge.portal_messages.load_earlier")}
119
+ </button>
120
+ </div>
121
+ ) : null}
122
+ <ol
123
+ role="log"
124
+ aria-label={tr("forge.portal_messages.conversation_label", { creator })}
125
+ className="fg:m-0 fg:flex fg:list-none fg:flex-col fg:gap-3 fg:p-0"
126
+ >
127
+ {messages.map((message) => (
128
+ <Bubble key={message.id} message={message} />
129
+ ))}
130
+ </ol>
131
+ </div>
132
+ );
133
+ }
134
+
135
+ function Bubble({ message }: { message: PortalMessage }) {
136
+ const t = useThemeTokens();
137
+ const tr = useForgeT();
138
+ const locale = useForgeLocale();
139
+ const mine = message.isMine;
140
+ const when = new Date(message.createdAt).toLocaleString(locale, {
141
+ day: "numeric",
142
+ month: "short",
143
+ hour: "numeric",
144
+ minute: "2-digit",
145
+ });
146
+ return (
147
+ <li
148
+ data-testid={mine ? "portal-message-mine" : "portal-message-theirs"}
149
+ className={`fg:flex fg:flex-col ${mine ? "fg:items-end" : "fg:items-start"}`}
150
+ >
151
+ <div
152
+ className="fg:max-w-[85%] fg:md:max-w-[70%]"
153
+ style={{
154
+ padding: "10px 14px",
155
+ borderRadius: t.cornerRadius,
156
+ background: mine ? t.primary : t.surface,
157
+ color: mine ? t.textPrimary : t.text,
158
+ border: mine ? `1px solid ${t.primary}` : `1px solid ${t.border}`,
159
+ fontSize: 15,
160
+ lineHeight: 1.5,
161
+ }}
162
+ >
163
+ {message.content ? <p className="fg:m-0 fg:whitespace-pre-wrap fg:break-words">{message.content}</p> : null}
164
+ <ChatAttachments attachments={message.attachments} />
165
+ </div>
166
+ <span style={{ marginTop: 4, fontSize: 12, color: t.muted }}>
167
+ {mine ? tr("forge.portal_messages.you") : message.senderName} · {when}
168
+ </span>
169
+ </li>
170
+ );
171
+ }
172
+
173
+ function Composer() {
174
+ const t = useThemeTokens();
175
+ const tr = useForgeT();
176
+ const buttons = usePortalButtons();
177
+ const send = useSendPortalMessage();
178
+ const upload = usePortalMessageAttachments();
179
+ const fileInput = useRef<HTMLInputElement | null>(null);
180
+ const [draft, setDraft] = useState("");
181
+ const [failed, setFailed] = useState(false);
182
+ const busy = send.isPending || upload.isUploading;
183
+ const canSend = !busy && (draft.trim().length > 0 || upload.hasPending);
184
+
185
+ const submit = async () => {
186
+ if (!canSend) return;
187
+ setFailed(false);
188
+ try {
189
+ const attachments = upload.hasPending ? await upload.uploadAll() : [];
190
+ await send.mutateAsync({ content: draft.trim(), attachments });
191
+ setDraft("");
192
+ upload.clear();
193
+ } catch {
194
+ // Keep the draft and the staged files so the person can try again.
195
+ setFailed(true);
196
+ }
197
+ };
198
+
199
+ return (
200
+ <form
201
+ data-testid="portal-messages-composer"
202
+ className="fg:mt-6"
203
+ onSubmit={(event) => {
204
+ event.preventDefault();
205
+ void submit();
206
+ }}
207
+ style={{ borderTop: `1px solid ${t.border}`, paddingTop: 16 }}
208
+ >
209
+ {failed || upload.error ? (
210
+ <p role="alert" style={{ margin: "0 0 8px", fontSize: 13, color: t.text }}>
211
+ {failed ? tr("forge.portal_messages.send_failed") : tr("forge.portal_messages.attachment_failed")}
212
+ </p>
213
+ ) : null}
214
+ {upload.pending.length > 0 ? (
215
+ <ul className="fg:m-0 fg:mb-3 fg:flex fg:list-none fg:flex-wrap fg:gap-2 fg:p-0">
216
+ {upload.pending.map((file) => (
217
+ <li
218
+ key={file.id}
219
+ className="fg:flex fg:max-w-[220px] fg:items-center fg:gap-2"
220
+ style={{
221
+ padding: "4px 8px",
222
+ border: `1px solid ${t.border}`,
223
+ borderRadius: t.cornerRadius,
224
+ fontSize: 12,
225
+ }}
226
+ >
227
+ <Paperclip aria-hidden size={14} color={t.muted} />
228
+ <span className="fg:min-w-0 fg:flex-1 fg:truncate">{file.file.name}</span>
229
+ <span style={{ color: t.muted }}>
230
+ {file.status === "uploading" ? `${file.progress}%` : formatBytes(file.file.size)}
231
+ </span>
232
+ <button
233
+ type="button"
234
+ aria-label={tr("forge.portal_messages.remove_attachment", { name: file.file.name })}
235
+ onClick={() => upload.removeFile(file.id)}
236
+ style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: t.muted }}
237
+ >
238
+ <X aria-hidden size={14} />
239
+ </button>
240
+ </li>
241
+ ))}
242
+ </ul>
243
+ ) : null}
244
+ <div className="fg:flex fg:items-end fg:gap-2">
245
+ <input
246
+ ref={fileInput}
247
+ type="file"
248
+ multiple
249
+ hidden
250
+ onChange={(event) => {
251
+ if (event.target.files) upload.addFiles(event.target.files);
252
+ event.target.value = "";
253
+ }}
254
+ />
255
+ <button
256
+ type="button"
257
+ aria-label={tr("forge.portal_messages.attach")}
258
+ title={tr("forge.portal_messages.attach")}
259
+ disabled={!upload.canAddMore || busy}
260
+ onClick={() => fileInput.current?.click()}
261
+ style={{ ...buttons.quiet, padding: "8px 10px" }}
262
+ >
263
+ <Paperclip aria-hidden size={16} />
264
+ </button>
265
+ <textarea
266
+ value={draft}
267
+ rows={2}
268
+ aria-label={tr("forge.portal_messages.placeholder")}
269
+ placeholder={tr("forge.portal_messages.placeholder")}
270
+ onChange={(event) => setDraft(event.target.value)}
271
+ onKeyDown={(event) => {
272
+ if (event.key === "Enter" && !event.shiftKey) {
273
+ event.preventDefault();
274
+ void submit();
275
+ }
276
+ }}
277
+ className="fg:min-w-0 fg:flex-1"
278
+ style={{
279
+ resize: "vertical",
280
+ padding: "10px 12px",
281
+ borderRadius: t.cornerRadius,
282
+ border: `1px solid ${t.border}`,
283
+ background: t.background,
284
+ color: t.text,
285
+ fontFamily: t.fontFamily,
286
+ fontSize: 15,
287
+ }}
288
+ />
289
+ <button
290
+ type="submit"
291
+ data-testid="portal-messages-send"
292
+ disabled={!canSend}
293
+ style={{ ...buttons.primary, opacity: canSend ? 1 : 0.6 }}
294
+ >
295
+ {busy ? tr("forge.portal_messages.sending") : tr("forge.portal_messages.send")}
296
+ </button>
297
+ </div>
298
+ </form>
299
+ );
300
+ }