@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
@@ -1,36 +1,38 @@
1
- import { ArrowRight, FolderKanban } from "lucide-react";
2
- import { useThemeTokens, type ResolvedThemeTokens } from "../../theme/ForgeThemeProvider";
1
+ import type { MouseEvent } from "react";
2
+ import { useForgeT } from "../../../i18n";
3
+ import { internalPath } from "../../../runtime/pages";
4
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
3
5
  import { Loading } from "../Loading";
4
- import {
5
- formatWorkDate,
6
- useWorkPortalProjects,
7
- type WorkProjectSummary,
8
- } from "../../headless/work/useWorkPortal";
6
+ import { useWorkPortalProjects, type WorkProjectSummary } from "../../headless/work/useWorkPortal";
7
+ import { PortalEmpty, PortalHeading, PortalList, PortalProgress, usePortal } from "../portal/shared";
8
+ import { workStatusLabel } from "./WorkReportBody";
9
9
 
10
10
  export interface WorkPortalProjectsProps {
11
- /** Build the href for a project's report page. Rendered as an `<a>` unless
12
- * `onOpenProject` is supplied. Defaults to `/i/work/projects/{id}`. */
11
+ /** Build the href for a project's report page. Defaults to
12
+ * `internalPath("account/projects/$projectId")`. */
13
13
  projectHref?: (projectId: string) => string;
14
- /** Router-driven open handler. When provided, cards render as buttons calling
15
- * this instead of anchors (lets the host app keep its own navigation/prefix). */
14
+ /** Router-driven open handler. When provided, rows stay real links (so a new
15
+ * tab still works) but a plain left click calls this instead of reloading. */
16
16
  onOpenProject?: (projectId: string) => void;
17
- /** Heading above the grid. Pass `null` to hide it. */
17
+ /** Heading above the list. Pass `null` to hide it. */
18
18
  heading?: string | null;
19
19
  }
20
20
 
21
21
  /**
22
22
  * The authenticated client's read-only list of their (non-archived) client
23
- * projects, as cards with a task/done progress bar. Built on
24
- * `useWorkPortalProjects`; theme tokens only. Tapping a card opens the project
25
- * report (`WorkProjectReport`).
23
+ * projects, as hairline-divided rows with a task/done progress line. Built on
24
+ * `useWorkPortalProjects`; theme tokens only. Opening a row goes to the
25
+ * project report (`WorkProjectReport`).
26
26
  */
27
27
  export function WorkPortalProjects({
28
- projectHref = (id) => `/i/work/projects/${id}`,
28
+ projectHref = (projectId) => internalPath("account/projects/$projectId", { projectId }),
29
29
  onOpenProject,
30
- heading = "Your projects",
30
+ heading,
31
31
  }: WorkPortalProjectsProps = {}) {
32
32
  const t = useThemeTokens();
33
+ const tr = useForgeT();
33
34
  const { data: projects, isLoading } = useWorkPortalProjects();
35
+ const title = heading === undefined ? tr("forge.work_portal_projects.title") : heading;
34
36
 
35
37
  if (isLoading) {
36
38
  return <Loading fullPage />;
@@ -38,126 +40,72 @@ export function WorkPortalProjects({
38
40
 
39
41
  if (!projects || projects.length === 0) {
40
42
  return (
41
- <div
42
- data-testid="work-portal-empty"
43
- style={{
44
- border: `1px solid ${t.border}`,
45
- borderRadius: t.cornerRadius,
46
- background: t.surface,
47
- padding: 32,
48
- textAlign: "center",
49
- color: t.text,
50
- fontFamily: t.fontFamily,
51
- }}
52
- >
53
- <FolderKanban size={28} style={{ margin: "0 auto 12px", color: t.primary }} />
54
- <p style={{ margin: 0, color: t.muted }}>You don&apos;t have any projects yet.</p>
43
+ <div data-testid="work-portal-empty" style={{ fontFamily: t.fontFamily }}>
44
+ {title ? <PortalHeading title={title} /> : null}
45
+ <PortalEmpty title={tr("forge.work_portal_projects.empty_title")} />
55
46
  </div>
56
47
  );
57
48
  }
58
49
 
59
50
  return (
60
- <div
61
- data-testid="work-portal-projects"
62
- style={{ display: "flex", flexDirection: "column", gap: 24, color: t.text, fontFamily: t.fontFamily }}
63
- >
64
- {heading && <h1 style={{ fontSize: 22, fontWeight: 800, margin: 0, color: t.text }}>{heading}</h1>}
65
- <div
66
- style={{
67
- display: "grid",
68
- gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
69
- gap: 12,
70
- }}
71
- >
51
+ <div data-testid="work-portal-projects" style={{ color: t.text, fontFamily: t.fontFamily }}>
52
+ {title ? <PortalHeading title={title} /> : null}
53
+ <PortalList>
72
54
  {projects.map((p) => (
73
- <ProjectCard
55
+ <ProjectRow
74
56
  key={p.id}
75
57
  project={p}
76
- t={t}
77
- href={onOpenProject ? undefined : projectHref(p.id)}
58
+ href={projectHref(p.id)}
78
59
  onOpen={onOpenProject ? () => onOpenProject(p.id) : undefined}
79
60
  />
80
61
  ))}
81
- </div>
62
+ </PortalList>
82
63
  </div>
83
64
  );
84
65
  }
85
66
 
86
- function ProjectCard({
87
- project,
88
- t,
89
- href,
90
- onOpen,
91
- }: {
92
- project: WorkProjectSummary;
93
- t: ResolvedThemeTokens;
94
- href?: string;
95
- onOpen?: () => void;
96
- }) {
67
+ function ProjectRow({ project, href, onOpen }: { project: WorkProjectSummary; href: string; onOpen?: () => void }) {
68
+ const t = useThemeTokens();
69
+ const tr = useForgeT();
70
+ const { formatDate } = usePortal();
97
71
  const pct = project.taskCount > 0 ? Math.round((project.doneCount / project.taskCount) * 100) : 0;
98
72
 
99
- const inner = (
100
- <>
101
- <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8 }}>
102
- <span style={{ fontWeight: 600, color: t.text }}>{project.name}</span>
103
- <ArrowRight size={16} style={{ color: t.muted, flexShrink: 0 }} />
104
- </div>
105
-
106
- <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
107
- <span
108
- style={{
109
- fontSize: 12,
110
- fontWeight: 600,
111
- padding: "2px 8px",
112
- borderRadius: 999,
113
- textTransform: "capitalize",
114
- background: t.border,
115
- color: t.primary,
116
- }}
117
- >
118
- {project.status.replace(/_/g, " ")}
119
- </span>
120
- <span style={{ fontSize: 12, color: t.muted }}>Due {formatWorkDate(project.dueDate)}</span>
121
- </div>
122
-
123
- <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
124
- <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", fontSize: 12, color: t.muted }}>
125
- <span>
126
- {project.doneCount} / {project.taskCount} tasks
127
- </span>
128
- <span>{pct}%</span>
129
- </div>
130
- <div style={{ width: "100%", height: 6, borderRadius: 999, overflow: "hidden", background: t.border }}>
131
- <div style={{ height: "100%", borderRadius: 999, width: `${pct}%`, background: t.primary }} />
132
- </div>
133
- </div>
134
- </>
135
- );
136
-
137
- const style: React.CSSProperties = {
138
- display: "flex",
139
- flexDirection: "column",
140
- gap: 12,
141
- textAlign: "left",
142
- padding: 20,
143
- cursor: "pointer",
144
- border: `1px solid ${t.border}`,
145
- borderRadius: t.cornerRadius,
146
- background: t.surface,
147
- color: t.text,
148
- textDecoration: "none",
149
- };
150
-
151
- if (onOpen) {
152
- return (
153
- <button type="button" data-testid="work-project-card" onClick={onOpen} style={style}>
154
- {inner}
155
- </button>
156
- );
157
- }
158
73
  return (
159
- <a data-testid="work-project-card" href={href} style={style}>
160
- {inner}
161
- </a>
74
+ <li style={{ listStyle: "none", borderBottom: `1px solid ${t.border}` }}>
75
+ <a
76
+ data-testid="work-project-card"
77
+ href={href}
78
+ className="fg:block fg:py-5"
79
+ style={{ color: t.text, textDecoration: "none" }}
80
+ onClick={
81
+ onOpen
82
+ ? (event: MouseEvent<HTMLAnchorElement>) => {
83
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
84
+ event.preventDefault();
85
+ onOpen();
86
+ }
87
+ : undefined
88
+ }
89
+ >
90
+ <span className="fg:flex fg:flex-wrap fg:items-baseline fg:justify-between fg:gap-3">
91
+ <span style={{ fontSize: 17, fontWeight: 600 }}>{project.name}</span>
92
+ <span style={{ fontSize: 13, color: t.muted }}>{workStatusLabel(tr, project.status)}</span>
93
+ </span>
94
+ <span style={{ display: "block", margin: "12px 0 8px" }}>
95
+ <PortalProgress
96
+ done={project.doneCount}
97
+ total={project.taskCount}
98
+ label={tr("forge.work_portal_projects.progress_label", { name: project.name })}
99
+ />
100
+ </span>
101
+ <span className="fg:flex fg:flex-wrap fg:gap-x-4" style={{ fontSize: 13, color: t.muted }}>
102
+ <span>{tr("forge.work_portal_projects.tasks_done", { done: project.doneCount, total: project.taskCount })}</span>
103
+ <span>{tr("forge.work_portal_projects.percent_done", { percent: pct })}</span>
104
+ {project.dueDate ? (
105
+ <span>{tr("forge.work_portal_projects.due_on", { date: formatDate(project.dueDate) })}</span>
106
+ ) : null}
107
+ </span>
108
+ </a>
109
+ </li>
162
110
  );
163
111
  }
@@ -1,13 +1,14 @@
1
1
  import { useRef, useState } from "react";
2
2
  import { FileText, Paperclip, Upload } from "lucide-react";
3
+ import { useForgeT, type ForgeT } from "../../../i18n";
3
4
  import { useThemeTokens } from "../../theme/ForgeThemeProvider";
4
5
  import {
5
- formatWorkDate,
6
6
  useUploadWorkDocument,
7
7
  useWorkOpenSharedDocument,
8
8
  useWorkSharedDocuments,
9
9
  type WorkSharedDocument,
10
10
  } from "../../headless/work/useWorkPortal";
11
+ import { PortalBlock, PortalEmpty, PortalList, PortalRow, usePortal, usePortalButtons } from "../portal/shared";
11
12
 
12
13
  export interface WorkProjectDocumentsProps {
13
14
  /** The client project id. */
@@ -28,9 +29,9 @@ const humanSize = (size: number | null | undefined): string => {
28
29
  return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
29
30
  };
30
31
 
31
- const errorMessage = (error: unknown): string =>
32
+ const errorMessage = (tr: ForgeT, error: unknown): string =>
32
33
  (error as { response?: { data?: { message?: string } } })?.response?.data?.message ||
33
- "The upload didn't go through. Please try again.";
34
+ tr("forge.work_project_documents.upload_error");
34
35
 
35
36
  /**
36
37
  * The documents on a client's project: what the firm shared with them, and
@@ -45,6 +46,9 @@ const errorMessage = (error: unknown): string =>
45
46
  */
46
47
  export function WorkProjectDocuments({ projectId, allowUpload = true }: WorkProjectDocumentsProps) {
47
48
  const t = useThemeTokens();
49
+ const tr = useForgeT();
50
+ const buttons = usePortalButtons();
51
+ const { formatDate } = usePortal();
48
52
  const { data: documents, isLoading } = useWorkSharedDocuments(projectId);
49
53
  const open = useWorkOpenSharedDocument(projectId);
50
54
  const upload = useUploadWorkDocument(projectId);
@@ -63,7 +67,9 @@ export function WorkProjectDocuments({ projectId, allowUpload = true }: WorkProj
63
67
  try {
64
68
  await upload.mutateAsync({ file });
65
69
  } catch (err) {
66
- setError(`${file.name}: ${errorMessage(err)}`);
70
+ setError(
71
+ tr("forge.work_project_documents.upload_error_file", { file_name: file.name, message: errorMessage(tr, err) }),
72
+ );
67
73
  return;
68
74
  }
69
75
  }
@@ -76,7 +82,7 @@ export function WorkProjectDocuments({ projectId, allowUpload = true }: WorkProj
76
82
  const { url } = await open.mutateAsync(doc.id);
77
83
  window.open(url, "_blank", "noopener");
78
84
  } catch (err) {
79
- setError(errorMessage(err));
85
+ setError(errorMessage(tr, err));
80
86
  } finally {
81
87
  setOpening(null);
82
88
  }
@@ -84,141 +90,106 @@ export function WorkProjectDocuments({ projectId, allowUpload = true }: WorkProj
84
90
 
85
91
  const list = documents ?? [];
86
92
 
87
- return (
88
- <div
89
- data-testid="work-project-documents"
90
- style={{
91
- border: `1px solid ${t.border}`,
92
- borderRadius: t.cornerRadius,
93
- background: t.surface,
94
- padding: 20,
95
- color: t.text,
96
- fontFamily: t.fontFamily,
97
- }}
98
- >
99
- <div
93
+ const uploadControl = allowUpload ? (
94
+ <>
95
+ <input
96
+ ref={fileInputRef}
97
+ type="file"
98
+ multiple
99
+ hidden
100
+ aria-label={tr("forge.work_project_documents.file_input_aria")}
101
+ data-testid="work-document-file-input"
102
+ onChange={(e) => {
103
+ void onFiles(e.target.files);
104
+ e.target.value = "";
105
+ }}
106
+ />
107
+ <button
108
+ type="button"
109
+ data-testid="work-document-upload"
110
+ onClick={() => fileInputRef.current?.click()}
111
+ disabled={upload.isPending}
100
112
  style={{
101
- display: "flex",
102
- alignItems: "center",
103
- justifyContent: "space-between",
104
- gap: 12,
105
- flexWrap: "wrap",
106
- marginBottom: 14,
113
+ ...buttons.quiet,
114
+ minHeight: 34,
115
+ padding: "6px 14px",
116
+ cursor: upload.isPending ? "wait" : "pointer",
117
+ opacity: upload.isPending ? 0.6 : 1,
107
118
  }}
108
119
  >
109
- <h2 style={{ fontSize: 16, fontWeight: 800, margin: 0 }}>Documents</h2>
110
- {allowUpload && (
111
- <>
112
- <input
113
- ref={fileInputRef}
114
- type="file"
115
- multiple
116
- hidden
117
- data-testid="work-document-file-input"
118
- onChange={(e) => {
119
- void onFiles(e.target.files);
120
- e.target.value = "";
121
- }}
122
- />
123
- <button
124
- type="button"
125
- data-testid="work-document-upload"
126
- onClick={() => fileInputRef.current?.click()}
127
- disabled={upload.isPending}
128
- style={{
129
- display: "inline-flex",
130
- alignItems: "center",
131
- gap: 8,
132
- padding: "8px 16px",
133
- borderRadius: t.cornerRadius,
134
- border: "none",
135
- background: t.primary,
136
- color: t.textPrimary,
137
- fontWeight: 700,
138
- fontSize: 13,
139
- cursor: upload.isPending ? "wait" : "pointer",
140
- opacity: upload.isPending ? 0.7 : 1,
141
- }}
142
- >
143
- <Upload size={14} />
144
- {upload.isPending ? "Uploading…" : "Upload a document"}
145
- </button>
146
- </>
147
- )}
148
- </div>
120
+ <Upload size={14} aria-hidden />
121
+ {upload.isPending ? tr("forge.work_project_documents.uploading") : tr("forge.work_project_documents.upload")}
122
+ </button>
123
+ </>
124
+ ) : null;
149
125
 
150
- {error && (
151
- <p
152
- data-testid="work-document-error"
153
- role="alert"
154
- style={{ margin: "0 0 12px", fontSize: 13, color: "#ef4444" }}
155
- >
156
- {error}
157
- </p>
158
- )}
126
+ return (
127
+ <div data-testid="work-project-documents" style={{ color: t.text, fontFamily: t.fontFamily }}>
128
+ <PortalBlock title={tr("forge.work_project_documents.title")} aside={uploadControl}>
129
+ {error && (
130
+ <p
131
+ data-testid="work-document-error"
132
+ role="alert"
133
+ style={{ margin: "0 0 12px", fontSize: 14, color: t.text, fontWeight: 600 }}
134
+ >
135
+ {error}
136
+ </p>
137
+ )}
159
138
 
160
- {list.length === 0 ? (
161
- <p data-testid="work-project-documents-empty" style={{ margin: 0, fontSize: 13, color: t.muted }}>
162
- {allowUpload
163
- ? "Nothing here yet. Upload a document to add it to this project."
164
- : "Nothing has been shared with you yet."}
165
- </p>
166
- ) : (
167
- <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
168
- {list.map((doc) => (
169
- <li
170
- key={doc.id}
171
- data-testid={`work-document-${doc.id}`}
172
- style={{
173
- display: "flex",
174
- alignItems: "center",
175
- justifyContent: "space-between",
176
- gap: 12,
177
- flexWrap: "wrap",
178
- padding: "10px 14px",
179
- border: `1px solid ${t.border}`,
180
- borderRadius: t.cornerRadius,
181
- }}
182
- >
183
- <div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
139
+ {list.length === 0 ? (
140
+ <div data-testid="work-project-documents-empty">
141
+ {allowUpload ? (
142
+ <PortalEmpty
143
+ title={tr("forge.work_project_documents.empty_title")}
144
+ body={tr("forge.work_project_documents.empty_body")}
145
+ />
146
+ ) : (
147
+ <PortalEmpty title={tr("forge.work_project_documents.empty_shared_title")} />
148
+ )}
149
+ </div>
150
+ ) : (
151
+ <PortalList>
152
+ {list.map((doc) => (
153
+ <PortalRow key={doc.id} testId={`work-document-${doc.id}`}>
184
154
  {doc.file ? (
185
- <Paperclip size={14} style={{ color: t.muted, flexShrink: 0 }} />
155
+ <Paperclip size={16} aria-hidden style={{ color: t.muted, flexShrink: 0 }} />
186
156
  ) : (
187
- <FileText size={14} style={{ color: t.muted, flexShrink: 0 }} />
157
+ <FileText size={16} aria-hidden style={{ color: t.muted, flexShrink: 0 }} />
188
158
  )}
189
- <div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
190
- <span style={{ fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
159
+ <div className="fg:flex fg:min-w-0 fg:flex-1 fg:flex-col fg:gap-1">
160
+ <span
161
+ style={{
162
+ fontWeight: 600,
163
+ fontSize: 15,
164
+ overflow: "hidden",
165
+ textOverflow: "ellipsis",
166
+ whiteSpace: "nowrap",
167
+ }}
168
+ >
191
169
  {doc.title}
192
170
  </span>
193
- <span style={{ fontSize: 12, color: t.muted }}>
194
- {formatWorkDate(doc.sharedAt)}
195
- {doc.file?.size ? ` · ${humanSize(doc.file.size)}` : ""}
171
+ <span className="fg:flex fg:flex-wrap fg:gap-x-3" style={{ fontSize: 13, color: t.muted }}>
172
+ <span>{tr("forge.work_project_documents.shared_on", { date: formatDate(doc.sharedAt) })}</span>
173
+ {doc.file?.size ? <span>{humanSize(doc.file.size)}</span> : null}
196
174
  </span>
197
175
  </div>
198
- </div>
199
- {doc.file && (
200
- <button
201
- type="button"
202
- data-testid={`work-document-open-${doc.id}`}
203
- onClick={() => void openDocument(doc)}
204
- disabled={opening === doc.id}
205
- style={{
206
- background: "transparent",
207
- border: "none",
208
- padding: 0,
209
- color: t.primary,
210
- fontSize: 13,
211
- textDecoration: "underline",
212
- cursor: opening === doc.id ? "wait" : "pointer",
213
- }}
214
- >
215
- {opening === doc.id ? "Opening…" : "Open"}
216
- </button>
217
- )}
218
- </li>
219
- ))}
220
- </ul>
221
- )}
176
+ {doc.file && (
177
+ <button
178
+ type="button"
179
+ data-testid={`work-document-open-${doc.id}`}
180
+ onClick={() => void openDocument(doc)}
181
+ disabled={opening === doc.id}
182
+ aria-label={tr("forge.work_project_documents.open_aria", { title: doc.title })}
183
+ style={{ ...buttons.text, cursor: opening === doc.id ? "wait" : "pointer" }}
184
+ >
185
+ {opening === doc.id ? tr("forge.work_project_documents.opening") : tr("forge.work_project_documents.open")}
186
+ </button>
187
+ )}
188
+ </PortalRow>
189
+ ))}
190
+ </PortalList>
191
+ )}
192
+ </PortalBlock>
222
193
  </div>
223
194
  );
224
195
  }