@tribe-nest/forge 3.60.0 → 3.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/package.json +5 -2
  2. package/src/contexts/CartContext.tsx +34 -1
  3. package/src/contexts/PublicAuthContext.tsx +15 -3
  4. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  5. package/src/data/queries/useAccount.ts +33 -2
  6. package/src/data/queries/useAnalytics.ts +6 -6
  7. package/src/data/queries/useCallChat.ts +2 -2
  8. package/src/data/queries/useChat.ts +10 -1
  9. package/src/data/queries/useCheckouts.ts +2 -1
  10. package/src/data/queries/useCoachingAvailability.ts +10 -1
  11. package/src/data/queries/useCommunities.ts +2 -0
  12. package/src/data/queries/useCommunity.ts +362 -18
  13. package/src/data/queries/useCommunityAgent.ts +247 -0
  14. package/src/data/queries/useCommunityCalendar.ts +150 -0
  15. package/src/data/queries/useCommunityClassroom.ts +75 -0
  16. package/src/data/queries/useCommunityMembers.ts +108 -0
  17. package/src/data/queries/useCommunityModeration.ts +327 -0
  18. package/src/data/queries/useCommunityWiki.ts +137 -0
  19. package/src/data/queries/useCourses.ts +2 -0
  20. package/src/data/queries/useEmailSignIn.ts +79 -0
  21. package/src/data/queries/useEvents.ts +6 -1
  22. package/src/data/queries/useForms.ts +5 -1
  23. package/src/data/queries/useLeadMagnet.ts +6 -2
  24. package/src/data/queries/useMyBookings.ts +24 -1
  25. package/src/data/queries/useMyTickets.ts +24 -1
  26. package/src/data/queries/useNotifications.ts +6 -2
  27. package/src/data/queries/useOrders.ts +2 -1
  28. package/src/data/queries/usePWA.ts +111 -35
  29. package/src/data/queries/useSiteApp.ts +130 -0
  30. package/src/data/queries/useSubscriptions.ts +6 -1
  31. package/src/i18n/de.json +746 -1
  32. package/src/i18n/en.json +746 -1
  33. package/src/index.ts +20 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/seo/_tests/seo.spec.ts +326 -0
  37. package/src/seo/applySeoToHead.ts +98 -0
  38. package/src/seo/index.ts +9 -0
  39. package/src/seo/resolve.ts +211 -0
  40. package/src/seo/seoHead.ts +61 -0
  41. package/src/seo/siteSeoHead.ts +97 -0
  42. package/src/seo/types.ts +99 -0
  43. package/src/server/_tests/siteBootstrap.spec.ts +65 -0
  44. package/src/server/index.ts +33 -4
  45. package/src/types/models.ts +11 -0
  46. package/src/ui/analytics/ForgeAnalytics.tsx +134 -34
  47. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  48. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  49. package/src/ui/analytics/_tests/ForgeAnalytics.webVitals.spec.tsx +129 -0
  50. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  51. package/src/ui/analytics/useMetaEvents.ts +78 -0
  52. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  53. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  54. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  55. package/src/ui/headless/_tests/communityHarness.tsx +10 -3
  56. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  57. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  58. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  59. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  60. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  61. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  62. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  63. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  64. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  65. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  66. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  67. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  68. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  69. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  70. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  71. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  72. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  73. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  74. package/src/ui/headless/communities/index.ts +41 -0
  75. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  76. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  77. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  78. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  79. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  80. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  81. package/src/ui/headless/community/communityPoll.ts +297 -0
  82. package/src/ui/headless/community/countries.ts +123 -0
  83. package/src/ui/headless/community/index.ts +81 -1
  84. package/src/ui/headless/community/linkPreview.ts +71 -0
  85. package/src/ui/headless/community/richLexical.ts +27 -2
  86. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  87. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  88. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  89. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  90. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  91. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  92. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  93. package/src/ui/headless/downloads/index.ts +8 -0
  94. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  95. package/src/ui/headless/index.ts +13 -0
  96. package/src/ui/headless/memberHome/index.ts +10 -0
  97. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  98. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  99. package/src/ui/headless/portal/index.ts +57 -0
  100. package/src/ui/headless/portal/usePortal.ts +274 -0
  101. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  102. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  103. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  104. package/src/ui/headless/siteApp/appBar.ts +102 -0
  105. package/src/ui/headless/siteApp/appHome.ts +344 -0
  106. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  107. package/src/ui/headless/siteApp/index.ts +58 -0
  108. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  109. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  110. package/src/ui/headless/work/index.ts +2 -0
  111. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  112. package/src/ui/index.ts +11 -0
  113. package/src/ui/styled/AccountDashboard.tsx +712 -352
  114. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  115. package/src/ui/styled/ChatRoom.tsx +126 -16
  116. package/src/ui/styled/Checkout.tsx +8 -0
  117. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  118. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  119. package/src/ui/styled/CoachingDetail.tsx +2 -0
  120. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  121. package/src/ui/styled/CourseDetail.tsx +2 -0
  122. package/src/ui/styled/DownloadPage.tsx +263 -0
  123. package/src/ui/styled/EventConfirmation.tsx +10 -0
  124. package/src/ui/styled/EventDetail.tsx +10 -0
  125. package/src/ui/styled/FilmLibrary.tsx +22 -9
  126. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  127. package/src/ui/styled/ProductDetail.tsx +12 -0
  128. package/src/ui/styled/PwaRegistration.tsx +5 -1
  129. package/src/ui/styled/SignupForm.tsx +18 -3
  130. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  131. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  132. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  133. package/src/ui/styled/app/AppBar.tsx +298 -0
  134. package/src/ui/styled/app/AppHome.tsx +1139 -0
  135. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  136. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  137. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  138. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  139. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  140. package/src/ui/styled/app/index.ts +7 -0
  141. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  142. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  143. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  144. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  145. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  146. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  147. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  148. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  149. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  150. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  151. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  152. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  153. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  154. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  155. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  156. package/src/ui/styled/communities/index.ts +9 -0
  157. package/src/ui/styled/communities/shared.tsx +39 -11
  158. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  159. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  160. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  161. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  162. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  163. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  164. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  165. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  166. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  167. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  168. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  169. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  170. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  171. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  172. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  173. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  174. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  175. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  176. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  177. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  178. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  179. package/src/ui/styled/community/index.ts +37 -2
  180. package/src/ui/styled/forge-utilities.css +234 -0
  181. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  182. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  183. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  184. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  185. package/src/ui/styled/members/index.ts +8 -0
  186. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  187. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  188. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  189. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  190. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  191. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  192. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  193. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  194. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  195. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  196. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  197. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  198. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  199. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  200. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  201. package/src/ui/styled/portal/index.ts +13 -0
  202. package/src/ui/styled/portal/portalNav.ts +260 -0
  203. package/src/ui/styled/portal/shared.tsx +206 -0
  204. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  205. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  206. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  207. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  208. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  209. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  210. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  211. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  212. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  213. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  214. package/src/ui/styled/work/index.ts +1 -1
  215. package/src/utils/_tests/landing.spec.ts +39 -0
  216. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  217. package/src/utils/_tests/structuredDataSeo.spec.ts +247 -0
  218. package/src/utils/landing.ts +36 -10
  219. package/src/utils/metaEvents.ts +302 -0
  220. package/src/utils/structuredData.ts +339 -1
@@ -0,0 +1,75 @@
1
+ "use client";
2
+ import { useState } from "react";
3
+ import { useForgeLocale, useForgeT } from "../../../i18n";
4
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
5
+ import { usePortalPreview } from "../../headless/portal/usePortalPreview";
6
+ import { usePortalButtons } from "./shared";
7
+
8
+ /** The preview's end as a wall-clock time in the visitor's locale. */
9
+ export const formatPreviewEnd = (expiresAt: string, locale: string): string =>
10
+ new Date(expiresAt).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
11
+
12
+ /**
13
+ * "Previewing as Pat Client. Read-only, ends 14:32." with an Exit button.
14
+ *
15
+ * Renders nothing unless this browser holds a read-only preview session
16
+ * (`user.preview`), so it is safe to mount unconditionally inside the portal
17
+ * frame. The sentence is state the creator acts on, not help, so it stays
18
+ * visible rather than behind a `?`.
19
+ */
20
+ export function PortalPreviewBanner() {
21
+ const t = useForgeT();
22
+ const locale = useForgeLocale();
23
+ const theme = useThemeTokens();
24
+ const buttons = usePortalButtons();
25
+ const { active, name, expiresAt, exit } = usePortalPreview();
26
+ const [exiting, setExiting] = useState(false);
27
+
28
+ if (!active) return null;
29
+
30
+ const message = expiresAt
31
+ ? t("forge.portal_preview.banner", { name, time: formatPreviewEnd(expiresAt, locale) })
32
+ : t("forge.portal_preview.banner_no_time", { name });
33
+
34
+ return (
35
+ <div
36
+ role="status"
37
+ data-portal-preview-banner=""
38
+ style={{
39
+ display: "flex",
40
+ flexWrap: "wrap",
41
+ alignItems: "center",
42
+ justifyContent: "space-between",
43
+ gap: 12,
44
+ padding: "10px 16px",
45
+ marginBottom: 16,
46
+ background: theme.primary,
47
+ color: theme.textPrimary,
48
+ borderRadius: theme.cornerRadius,
49
+ fontFamily: theme.fontFamily,
50
+ fontSize: 14,
51
+ fontWeight: 600,
52
+ }}
53
+ >
54
+ <span>{message}</span>
55
+ <button
56
+ type="button"
57
+ disabled={exiting}
58
+ onClick={async () => {
59
+ setExiting(true);
60
+ await exit();
61
+ }}
62
+ style={{
63
+ ...buttons.quiet,
64
+ minHeight: 32,
65
+ padding: "4px 14px",
66
+ color: theme.textPrimary,
67
+ borderColor: theme.textPrimary,
68
+ opacity: exiting ? 0.6 : 1,
69
+ }}
70
+ >
71
+ {exiting ? t("forge.portal_preview.exiting") : t("forge.portal_preview.exit")}
72
+ </button>
73
+ </div>
74
+ );
75
+ }
@@ -0,0 +1,407 @@
1
+ import { useState } from "react";
2
+ import { ArrowLeft, Check } from "lucide-react";
3
+ import { useForgeT } from "../../../i18n";
4
+ import { internalPath } from "../../../runtime/pages";
5
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
6
+ import { useWorkProjectReport, type WorkMilestone } from "../../headless/work/useWorkPortal";
7
+ import {
8
+ useDecideApproval,
9
+ useWorkApprovalRequests,
10
+ type WorkApprovalRequest,
11
+ } from "../../headless/portal/usePortal";
12
+ import { Loading } from "../Loading";
13
+ import { WorkReportBody, workStatusLabel } from "../work/WorkReportBody";
14
+ import { WorkPortalProjects } from "../work/WorkPortalProjects";
15
+ import { WorkProjectDocuments } from "../work/WorkProjectDocuments";
16
+ import { WorkProjectInvoices } from "../work/WorkProjectInvoices";
17
+ import { WorkTaskDetail } from "../work/WorkTaskDetail";
18
+ import {
19
+ PortalBlock,
20
+ PortalEmpty,
21
+ PortalHeading,
22
+ PortalLink,
23
+ PortalList,
24
+ PortalProgress,
25
+ usePortal,
26
+ usePortalButtons,
27
+ } from "./shared";
28
+
29
+ const projectHref = (projectId: string) => internalPath("account/projects/$projectId", { projectId });
30
+
31
+ /**
32
+ * Every project the person is a client on (`/i/account/projects`).
33
+ *
34
+ * The list is `WorkPortalProjects`, so the portal and a creator's own custom
35
+ * portal draw the same rows; only navigation is the portal's.
36
+ */
37
+ export function PortalProjects() {
38
+ const { navigate } = usePortal();
39
+ return <WorkPortalProjects onOpenProject={(projectId) => navigate(projectHref(projectId))} />;
40
+ }
41
+
42
+ function StatusText({ status }: { status: string }) {
43
+ const t = useThemeTokens();
44
+ const tr = useForgeT();
45
+ return (
46
+ <span data-testid="work-project-status" style={{ fontSize: 13, color: t.muted }}>
47
+ {workStatusLabel(tr, status)}
48
+ </span>
49
+ );
50
+ }
51
+
52
+ /**
53
+ * One project (`/i/account/projects/$projectId`).
54
+ *
55
+ * Leads with where it stands (progress, the next milestone) and with anything
56
+ * waiting on the client's decision. Then the milestones as the sequence they
57
+ * are, the tasks, billable time, shared files and invoices.
58
+ */
59
+ export function PortalProject({ projectId }: { projectId: string }) {
60
+ const t = useThemeTokens();
61
+ const tr = useForgeT();
62
+ const { formatDate } = usePortal();
63
+ const buttons = usePortalButtons();
64
+ const { data: report, isLoading, isError } = useWorkProjectReport(projectId);
65
+ const { data: approvalData } = useWorkApprovalRequests(projectId);
66
+ const approvals = approvalData?.requests;
67
+ const canApprove = approvalData?.canApprove ?? false;
68
+ const { navigate } = usePortal();
69
+
70
+ if (isLoading) return <Loading />;
71
+ if (isError || !report) {
72
+ return (
73
+ <div data-testid="work-project-report-error">
74
+ <PortalEmpty
75
+ title={tr("forge.portal_projects.not_found_title")}
76
+ body={tr("forge.portal_projects.not_found_body")}
77
+ action={
78
+ <PortalLink href={internalPath("account/projects")} style={buttons.quiet}>
79
+ {tr("forge.portal_projects.back_to_projects")}
80
+ </PortalLink>
81
+ }
82
+ />
83
+ </div>
84
+ );
85
+ }
86
+
87
+ const { project, milestones, tasks } = report;
88
+ const done = tasks.filter((task) => task.done).length;
89
+ const next = milestones.find((m) => m.status !== "completed");
90
+ const pending = (approvals ?? []).filter((a) => a.status === "pending");
91
+ const decided = (approvals ?? []).filter((a) => a.status !== "pending" && a.status !== "cancelled");
92
+
93
+ return (
94
+ <div data-testid="portal-project">
95
+ <BackLink href={internalPath("account/projects")} label={tr("forge.portal_projects.back_to_projects")} />
96
+ <PortalHeading
97
+ title={<span data-testid="work-project-name">{project.name}</span>}
98
+ lead={
99
+ next
100
+ ? next.dueDate
101
+ ? tr("forge.portal_projects.next_milestone_due", { name: next.name, date: formatDate(next.dueDate) })
102
+ : tr("forge.portal_projects.next_milestone", { name: next.name })
103
+ : milestones.length > 0
104
+ ? tr("forge.portal_projects.all_milestones_done")
105
+ : undefined
106
+ }
107
+ actions={<StatusText status={project.status} />}
108
+ />
109
+ {tasks.length > 0 ? (
110
+ <div style={{ marginTop: -8, marginBottom: 32 }}>
111
+ <PortalProgress
112
+ done={done}
113
+ total={tasks.length}
114
+ label={tr("forge.portal_projects.progress_label", { name: project.name })}
115
+ />
116
+ <p style={{ margin: "8px 0 0", fontSize: 13, color: t.muted }}>
117
+ {tr("forge.portal_projects.tasks_done", { done, total: tasks.length })}
118
+ </p>
119
+ </div>
120
+ ) : null}
121
+
122
+ {/* Only for a client who may decide. A view-only seat sees the same
123
+ request as "Awaiting approval" on the timeline below. */}
124
+ {pending.length > 0 && canApprove ? (
125
+ <section id="approvals" data-testid="portal-approvals" style={{ marginBottom: 40 }}>
126
+ <h2 style={{ margin: "0 0 12px", fontSize: 18, fontWeight: 600, fontFamily: t.headingFontFamily }}>
127
+ {tr("forge.portal_projects.awaiting_you")}
128
+ </h2>
129
+ {pending.map((request) => (
130
+ <ApprovalCard key={request.id} projectId={projectId} request={request} />
131
+ ))}
132
+ </section>
133
+ ) : null}
134
+
135
+ {milestones.length > 0 ? (
136
+ <PortalBlock title={tr("forge.portal_projects.milestones")}>
137
+ <MilestoneTimeline milestones={milestones} approvals={approvals ?? []} canApprove={canApprove} />
138
+ </PortalBlock>
139
+ ) : null}
140
+
141
+ <div style={{ marginTop: 40 }}>
142
+ <WorkReportBody
143
+ report={report}
144
+ sections={["tasks", "time"]}
145
+ taskHref={(taskId) => internalPath("account/projects/$projectId/tasks/$taskId", { projectId, taskId })}
146
+ onOpenTask={(taskId) =>
147
+ navigate(internalPath("account/projects/$projectId/tasks/$taskId", { projectId, taskId }))
148
+ }
149
+ />
150
+ </div>
151
+
152
+ <div style={{ marginTop: 24, display: "flex", flexDirection: "column", gap: 24 }}>
153
+ <WorkProjectDocuments projectId={projectId} />
154
+ <WorkProjectInvoices projectId={projectId} onOpenInvoice={(id) => navigate(internalPath("invoice/$invoiceId", { invoiceId: id }))} />
155
+ </div>
156
+
157
+ {decided.length > 0 ? (
158
+ <PortalBlock title={tr("forge.portal_projects.decisions")}>
159
+ <PortalList>
160
+ {decided.map((request) => (
161
+ <li
162
+ key={request.id}
163
+ data-testid="portal-approval-history"
164
+ className="fg:py-3"
165
+ style={{ borderBottom: `1px solid ${t.border}`, fontSize: 14 }}
166
+ >
167
+ <span style={{ fontWeight: 600 }}>{request.milestoneName}</span>{" "}
168
+ <span style={{ color: t.muted }}>
169
+ {request.status === "approved"
170
+ ? tr("forge.portal_projects.history_approved", {
171
+ name: request.decidedByName ?? "",
172
+ date: request.decidedAt ? formatDate(request.decidedAt) : "",
173
+ })
174
+ : tr("forge.portal_projects.history_changes", {
175
+ name: request.decidedByName ?? "",
176
+ date: request.decidedAt ? formatDate(request.decidedAt) : "",
177
+ })}
178
+ </span>
179
+ {request.decisionComment ? (
180
+ <p style={{ margin: "4px 0 0", color: t.muted }}>{request.decisionComment}</p>
181
+ ) : null}
182
+ </li>
183
+ ))}
184
+ </PortalList>
185
+ </PortalBlock>
186
+ ) : null}
187
+ </div>
188
+ );
189
+ }
190
+
191
+ function BackLink({ href, label }: { href: string; label: string }) {
192
+ const t = useThemeTokens();
193
+ return (
194
+ <PortalLink
195
+ href={href}
196
+ data-testid="portal-back"
197
+ className="fg:mb-4 fg:inline-flex fg:items-center fg:gap-1.5"
198
+ style={{ fontSize: 14, color: t.muted, textDecoration: "none" }}
199
+ >
200
+ <ArrowLeft size={14} aria-hidden /> {label}
201
+ </PortalLink>
202
+ );
203
+ }
204
+
205
+ /**
206
+ * A decision the creator has asked for.
207
+ *
208
+ * Approving is one click. Asking for changes opens a comment box, because a
209
+ * "no" without a reason is a round trip of email the portal exists to save.
210
+ */
211
+ function ApprovalCard({ projectId, request }: { projectId: string; request: WorkApprovalRequest }) {
212
+ const t = useThemeTokens();
213
+ const tr = useForgeT();
214
+ const { formatDate } = usePortal();
215
+ const buttons = usePortalButtons();
216
+ const decide = useDecideApproval(projectId);
217
+ const [mode, setMode] = useState<"idle" | "changes">("idle");
218
+ const [comment, setComment] = useState("");
219
+ const [error, setError] = useState<string | null>(null);
220
+
221
+ const submit = async (decision: "approve" | "request_changes") => {
222
+ setError(null);
223
+ try {
224
+ await decide.mutateAsync({ requestId: request.id, decision, comment: decision === "request_changes" ? comment.trim() : undefined });
225
+ } catch (err) {
226
+ const message =
227
+ (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
228
+ tr("forge.portal_projects.decision_error");
229
+ setError(message);
230
+ }
231
+ };
232
+
233
+ return (
234
+ <div
235
+ data-testid="portal-approval-card"
236
+ style={{
237
+ borderLeft: `3px solid ${t.primary}`,
238
+ background: t.surface,
239
+ borderRadius: `0 ${t.cornerRadius}px ${t.cornerRadius}px 0`,
240
+ padding: "16px 20px",
241
+ marginBottom: 12,
242
+ }}
243
+ >
244
+ <p style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>
245
+ {tr("forge.portal_projects.review_milestone", { name: request.milestoneName })}
246
+ </p>
247
+ <p style={{ margin: "4px 0 0", fontSize: 14, color: t.muted }}>
248
+ {tr("forge.portal_projects.requested_on", { date: formatDate(request.requestedAt) })}
249
+ </p>
250
+ {request.note ? (
251
+ <blockquote style={{ margin: "12px 0 0", paddingLeft: 12, borderLeft: `2px solid ${t.border}`, fontSize: 15 }}>
252
+ {request.note}
253
+ </blockquote>
254
+ ) : null}
255
+
256
+ {mode === "changes" ? (
257
+ <div style={{ marginTop: 16 }}>
258
+ <label htmlFor={`changes-${request.id}`} style={{ display: "block", fontSize: 14, fontWeight: 600, marginBottom: 6 }}>
259
+ {tr("forge.portal_projects.changes_label")}
260
+ </label>
261
+ <textarea
262
+ id={`changes-${request.id}`}
263
+ data-testid="portal-approval-comment"
264
+ value={comment}
265
+ onChange={(e) => setComment(e.target.value)}
266
+ rows={4}
267
+ maxLength={4000}
268
+ style={{
269
+ width: "100%",
270
+ boxSizing: "border-box",
271
+ padding: "10px 12px",
272
+ borderRadius: t.cornerRadius,
273
+ border: `1px solid ${t.border}`,
274
+ background: t.background,
275
+ color: t.text,
276
+ fontFamily: t.fontFamily,
277
+ fontSize: 15,
278
+ }}
279
+ />
280
+ <div className="fg:mt-3 fg:flex fg:flex-wrap fg:gap-2">
281
+ <button
282
+ type="button"
283
+ data-testid="portal-approval-send-changes"
284
+ style={{ ...buttons.primary, opacity: comment.trim() ? 1 : 0.5 }}
285
+ disabled={!comment.trim() || decide.isPending}
286
+ onClick={() => void submit("request_changes")}
287
+ >
288
+ {tr("forge.portal_projects.send_changes")}
289
+ </button>
290
+ <button type="button" style={buttons.quiet} onClick={() => setMode("idle")} disabled={decide.isPending}>
291
+ {tr("forge.portal_projects.cancel")}
292
+ </button>
293
+ </div>
294
+ </div>
295
+ ) : (
296
+ <div className="fg:mt-4 fg:flex fg:flex-wrap fg:gap-2">
297
+ <button
298
+ type="button"
299
+ data-testid="portal-approval-approve"
300
+ style={buttons.primary}
301
+ disabled={decide.isPending}
302
+ onClick={() => void submit("approve")}
303
+ >
304
+ <Check size={16} aria-hidden /> {tr("forge.portal_projects.approve")}
305
+ </button>
306
+ <button
307
+ type="button"
308
+ data-testid="portal-approval-request-changes"
309
+ style={buttons.quiet}
310
+ disabled={decide.isPending}
311
+ onClick={() => setMode("changes")}
312
+ >
313
+ {tr("forge.portal_projects.request_changes")}
314
+ </button>
315
+ </div>
316
+ )}
317
+ {error ? (
318
+ <p role="alert" style={{ margin: "10px 0 0", fontSize: 14, color: t.text, fontWeight: 600 }}>
319
+ {error}
320
+ </p>
321
+ ) : null}
322
+ </div>
323
+ );
324
+ }
325
+
326
+ /**
327
+ * Milestones in order, joined by a line, because they are a sequence: the
328
+ * numbering and the rail say "this comes before that", which a list of cards
329
+ * does not.
330
+ */
331
+ function MilestoneTimeline({
332
+ milestones,
333
+ approvals,
334
+ canApprove,
335
+ }: {
336
+ milestones: WorkMilestone[];
337
+ approvals: WorkApprovalRequest[];
338
+ canApprove: boolean;
339
+ }) {
340
+ const t = useThemeTokens();
341
+ const tr = useForgeT();
342
+ const { formatDate } = usePortal();
343
+
344
+ const latestFor = (milestoneId: string) => approvals.find((a) => a.milestoneId === milestoneId && a.status !== "cancelled");
345
+
346
+ return (
347
+ <ol style={{ listStyle: "none", margin: 0, padding: 0 }}>
348
+ {milestones.map((m, index) => {
349
+ const complete = m.status === "completed";
350
+ const approval = latestFor(m.id);
351
+ const last = index === milestones.length - 1;
352
+ return (
353
+ <li key={m.id} data-testid="portal-milestone" className="fg:flex fg:gap-4">
354
+ <div className="fg:flex fg:flex-col fg:items-center" aria-hidden>
355
+ <span
356
+ style={{
357
+ width: 26,
358
+ height: 26,
359
+ borderRadius: 13,
360
+ display: "inline-flex",
361
+ alignItems: "center",
362
+ justifyContent: "center",
363
+ fontSize: 12,
364
+ fontWeight: 700,
365
+ background: complete ? t.primary : t.background,
366
+ color: complete ? t.textPrimary : t.muted,
367
+ border: `2px solid ${complete ? t.primary : t.border}`,
368
+ }}
369
+ >
370
+ {complete ? <Check size={14} /> : index + 1}
371
+ </span>
372
+ {!last ? <span style={{ flex: 1, width: 2, minHeight: 20, background: t.border }} /> : null}
373
+ </div>
374
+ <div className="fg:min-w-0 fg:flex-1" style={{ paddingBottom: last ? 0 : 20 }}>
375
+ <p style={{ margin: "2px 0 0", fontWeight: 600, color: complete ? t.muted : t.text }}>{m.name}</p>
376
+ <p className="fg:flex fg:flex-wrap fg:gap-x-3" style={{ margin: "2px 0 0", fontSize: 13, color: t.muted }}>
377
+ {m.dueDate ? <span>{tr("forge.portal_projects.due_on", { date: formatDate(m.dueDate) })}</span> : null}
378
+ {approval?.status === "pending" ? (
379
+ <span>
380
+ {canApprove
381
+ ? tr("forge.portal_projects.awaiting_approval")
382
+ : tr("forge.portal_projects.awaiting_approval_other")}
383
+ </span>
384
+ ) : null}
385
+ {approval?.status === "approved" ? <span>{tr("forge.portal_projects.approved")}</span> : null}
386
+ {approval?.status === "changes_requested" ? <span>{tr("forge.portal_projects.changes_requested")}</span> : null}
387
+ </p>
388
+ </div>
389
+ </li>
390
+ );
391
+ })}
392
+ </ol>
393
+ );
394
+ }
395
+
396
+ /** One task (`/i/account/projects/$projectId/tasks/$taskId`). */
397
+ export function PortalTask({ projectId, taskId }: { projectId: string; taskId: string }) {
398
+ const { navigate } = usePortal();
399
+ return (
400
+ <WorkTaskDetail
401
+ projectId={projectId}
402
+ taskId={taskId}
403
+ backHref={projectHref(projectId)}
404
+ onBack={() => navigate(projectHref(projectId))}
405
+ />
406
+ );
407
+ }