@tribe-nest/forge 1.13.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 (255) hide show
  1. package/package.json +42 -0
  2. package/src/client/createForgeClient.ts +54 -0
  3. package/src/content/ForgeContentProvider.tsx +118 -0
  4. package/src/content/ForgeEditBridge.tsx +134 -0
  5. package/src/content/editFlag.ts +23 -0
  6. package/src/content/index.ts +28 -0
  7. package/src/content/resolve.ts +67 -0
  8. package/src/content/types.ts +97 -0
  9. package/src/content/useField.ts +64 -0
  10. package/src/contexts/AppAuthContext.tsx +185 -0
  11. package/src/contexts/AudioPlayerContext.tsx +389 -0
  12. package/src/contexts/CartContext.tsx +131 -0
  13. package/src/contexts/PublicAuthContext.tsx +333 -0
  14. package/src/contexts/useAppAdminGuard.ts +56 -0
  15. package/src/css.d.ts +5 -0
  16. package/src/data/collectionParams.ts +57 -0
  17. package/src/data/queries/useAccount.ts +71 -0
  18. package/src/data/queries/useAccountSettings.ts +82 -0
  19. package/src/data/queries/useAnalytics.ts +86 -0
  20. package/src/data/queries/useAuthActions.ts +28 -0
  21. package/src/data/queries/useBlog.ts +103 -0
  22. package/src/data/queries/useBlogComments.ts +96 -0
  23. package/src/data/queries/useBroadcasts.ts +31 -0
  24. package/src/data/queries/useCertificates.ts +58 -0
  25. package/src/data/queries/useChat.ts +267 -0
  26. package/src/data/queries/useCoachingAvailability.ts +69 -0
  27. package/src/data/queries/useCoachingProducts.ts +67 -0
  28. package/src/data/queries/useCohortPage.ts +17 -0
  29. package/src/data/queries/useCollections.ts +211 -0
  30. package/src/data/queries/useCommunity.ts +794 -0
  31. package/src/data/queries/useCoupons.ts +20 -0
  32. package/src/data/queries/useCourseAccess.ts +50 -0
  33. package/src/data/queries/useCourses.ts +91 -0
  34. package/src/data/queries/useCurrencies.ts +33 -0
  35. package/src/data/queries/useDocuments.ts +208 -0
  36. package/src/data/queries/useDonations.ts +29 -0
  37. package/src/data/queries/useEngagement.ts +115 -0
  38. package/src/data/queries/useEvents.ts +69 -0
  39. package/src/data/queries/useFinalize.ts +109 -0
  40. package/src/data/queries/useForms.ts +31 -0
  41. package/src/data/queries/useInvoice.ts +65 -0
  42. package/src/data/queries/useLeadMagnet.ts +96 -0
  43. package/src/data/queries/useMembership.ts +20 -0
  44. package/src/data/queries/useNotifications.ts +267 -0
  45. package/src/data/queries/useOffers.ts +56 -0
  46. package/src/data/queries/useOrders.ts +71 -0
  47. package/src/data/queries/usePWA.ts +101 -0
  48. package/src/data/queries/usePageActions.ts +36 -0
  49. package/src/data/queries/usePaymentFlow.ts +80 -0
  50. package/src/data/queries/usePaymentLink.ts +47 -0
  51. package/src/data/queries/usePodcast.ts +85 -0
  52. package/src/data/queries/usePostCollections.ts +48 -0
  53. package/src/data/queries/usePosts.ts +94 -0
  54. package/src/data/queries/useProducts.ts +108 -0
  55. package/src/data/queries/useReplay.ts +19 -0
  56. package/src/data/queries/useReviews.ts +122 -0
  57. package/src/data/queries/useShipping.ts +109 -0
  58. package/src/data/queries/useSubscriptions.ts +66 -0
  59. package/src/data/queries/useWebsite.ts +128 -0
  60. package/src/data/queries/useWebsiteAgent.ts +100 -0
  61. package/src/index.ts +98 -0
  62. package/src/provider/ForgeAppProvider.tsx +149 -0
  63. package/src/provider/ForgeProvider.tsx +91 -0
  64. package/src/provider/SiteConfigProvider.tsx +27 -0
  65. package/src/server/_tests/buildPwaHead.spec.ts +182 -0
  66. package/src/server/_tests/buildWebManifest.spec.ts +313 -0
  67. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
  68. package/src/server/index.ts +260 -0
  69. package/src/server/jobs.ts +104 -0
  70. package/src/server/pwa.ts +332 -0
  71. package/src/types/index.ts +74 -0
  72. package/src/types/models.ts +1313 -0
  73. package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
  74. package/src/ui/analytics/PageMetaPixel.tsx +92 -0
  75. package/src/ui/content/Editable.tsx +90 -0
  76. package/src/ui/format/PriceDisplay.tsx +142 -0
  77. package/src/ui/format/useFormatCurrency.ts +92 -0
  78. package/src/ui/headless/agent/useAiAgent.ts +106 -0
  79. package/src/ui/headless/auth/useLoginFlow.ts +72 -0
  80. package/src/ui/headless/auth/useSignupForm.ts +85 -0
  81. package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
  82. package/src/ui/headless/chat/attachmentKind.ts +27 -0
  83. package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
  84. package/src/ui/headless/chat/useChatRoom.ts +223 -0
  85. package/src/ui/headless/chat/useMessageThread.ts +60 -0
  86. package/src/ui/headless/checkout/useCheckout.ts +438 -0
  87. package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
  88. package/src/ui/headless/community/index.ts +20 -0
  89. package/src/ui/headless/community/lexical.ts +131 -0
  90. package/src/ui/headless/community/replyTree.ts +51 -0
  91. package/src/ui/headless/community/richLexical.ts +314 -0
  92. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
  93. package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
  94. package/src/ui/headless/community/useCommunityThread.ts +95 -0
  95. package/src/ui/headless/community/useMentionComposer.ts +105 -0
  96. package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
  97. package/src/ui/headless/consent/useCookieConsent.ts +81 -0
  98. package/src/ui/headless/course/useCourseCheckout.ts +99 -0
  99. package/src/ui/headless/dialog.tsx +14 -0
  100. package/src/ui/headless/document/index.ts +7 -0
  101. package/src/ui/headless/document/useContractSignature.ts +68 -0
  102. package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
  103. package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
  104. package/src/ui/headless/document/useSignaturePad.ts +180 -0
  105. package/src/ui/headless/donation/Donation.tsx +115 -0
  106. package/src/ui/headless/donation/DonationContext.tsx +223 -0
  107. package/src/ui/headless/donation/fees.ts +15 -0
  108. package/src/ui/headless/donation/index.ts +10 -0
  109. package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
  110. package/src/ui/headless/event/useEventCheckout.ts +141 -0
  111. package/src/ui/headless/forms/useContactForm.ts +58 -0
  112. package/src/ui/headless/forms/useEmailListForm.ts +75 -0
  113. package/src/ui/headless/forms/useSectionedForm.ts +256 -0
  114. package/src/ui/headless/index.ts +60 -0
  115. package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
  116. package/src/ui/headless/memberHome/index.ts +7 -0
  117. package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
  118. package/src/ui/headless/membership/MembershipGate.tsx +39 -0
  119. package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
  120. package/src/ui/headless/offer/Offer.tsx +88 -0
  121. package/src/ui/headless/offer/OfferContext.tsx +162 -0
  122. package/src/ui/headless/offer/index.ts +8 -0
  123. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
  124. package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
  125. package/src/ui/headless/posts/index.ts +13 -0
  126. package/src/ui/headless/posts/upsell.ts +41 -0
  127. package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
  128. package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
  129. package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
  130. package/src/ui/headless/posts/usePostItem.ts +81 -0
  131. package/src/ui/headless/reviews/index.ts +6 -0
  132. package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
  133. package/src/ui/headless/reviews/useReviewForm.ts +108 -0
  134. package/src/ui/headless/reviews/useReviewsList.ts +79 -0
  135. package/src/ui/headless/work/index.ts +25 -0
  136. package/src/ui/headless/work/useWorkPortal.ts +349 -0
  137. package/src/ui/index.ts +144 -0
  138. package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
  139. package/src/ui/payment/ForgeStripePayment.tsx +171 -0
  140. package/src/ui/shell/TribeNestApp.tsx +121 -0
  141. package/src/ui/shell/shellGating.spec.ts +28 -0
  142. package/src/ui/shell/shellGating.ts +11 -0
  143. package/src/ui/styled/AccountDashboard.tsx +575 -0
  144. package/src/ui/styled/AiAgentWidget.tsx +152 -0
  145. package/src/ui/styled/AudioPlayer.tsx +123 -0
  146. package/src/ui/styled/BlogCategory.tsx +79 -0
  147. package/src/ui/styled/BlogComments.tsx +236 -0
  148. package/src/ui/styled/BlogList.tsx +101 -0
  149. package/src/ui/styled/BlogPost.tsx +160 -0
  150. package/src/ui/styled/BlogPostFooter.tsx +33 -0
  151. package/src/ui/styled/BotProtection.tsx +101 -0
  152. package/src/ui/styled/Button.tsx +88 -0
  153. package/src/ui/styled/Cart.tsx +276 -0
  154. package/src/ui/styled/ChatRoom.tsx +252 -0
  155. package/src/ui/styled/Checkout.tsx +988 -0
  156. package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
  157. package/src/ui/styled/CoachingBooking.tsx +605 -0
  158. package/src/ui/styled/CoachingConfirmation.tsx +151 -0
  159. package/src/ui/styled/CoachingDetail.tsx +99 -0
  160. package/src/ui/styled/CohortPage.tsx +82 -0
  161. package/src/ui/styled/ConfirmSubscription.tsx +134 -0
  162. package/src/ui/styled/Confirmation.tsx +171 -0
  163. package/src/ui/styled/ContactForm.tsx +81 -0
  164. package/src/ui/styled/CookieConsent.tsx +239 -0
  165. package/src/ui/styled/CourseAccess.tsx +169 -0
  166. package/src/ui/styled/CourseCheckout.tsx +425 -0
  167. package/src/ui/styled/CourseConfirmation.tsx +191 -0
  168. package/src/ui/styled/CourseDetail.tsx +140 -0
  169. package/src/ui/styled/CoursesGrid.tsx +85 -0
  170. package/src/ui/styled/CurrencySwitcher.tsx +51 -0
  171. package/src/ui/styled/DonationButton.tsx +247 -0
  172. package/src/ui/styled/DonationPage.tsx +29 -0
  173. package/src/ui/styled/EmailListForm.tsx +88 -0
  174. package/src/ui/styled/EventConfirmation.tsx +228 -0
  175. package/src/ui/styled/EventCountdown.tsx +98 -0
  176. package/src/ui/styled/EventDetail.tsx +141 -0
  177. package/src/ui/styled/EventTickets.tsx +548 -0
  178. package/src/ui/styled/EventsList.tsx +83 -0
  179. package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
  180. package/src/ui/styled/FormRenderer.tsx +249 -0
  181. package/src/ui/styled/InstallBanner.tsx +114 -0
  182. package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
  183. package/src/ui/styled/InvoicePayment.tsx +141 -0
  184. package/src/ui/styled/LeadMagnet.tsx +93 -0
  185. package/src/ui/styled/Loading.tsx +56 -0
  186. package/src/ui/styled/LoginForm.tsx +134 -0
  187. package/src/ui/styled/Markdown.tsx +103 -0
  188. package/src/ui/styled/MembershipCheckout.tsx +197 -0
  189. package/src/ui/styled/MembershipTiers.tsx +115 -0
  190. package/src/ui/styled/OfferButton.tsx +181 -0
  191. package/src/ui/styled/PageActions.tsx +124 -0
  192. package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
  193. package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
  194. package/src/ui/styled/Paywall.tsx +54 -0
  195. package/src/ui/styled/PlayButton.tsx +48 -0
  196. package/src/ui/styled/PodcastEpisode.tsx +137 -0
  197. package/src/ui/styled/PodcastList.tsx +58 -0
  198. package/src/ui/styled/PodcastShow.tsx +164 -0
  199. package/src/ui/styled/PostsFeed.tsx +63 -0
  200. package/src/ui/styled/ProductDetail.tsx +616 -0
  201. package/src/ui/styled/ProductGrid.tsx +82 -0
  202. package/src/ui/styled/PushOptIn.tsx +65 -0
  203. package/src/ui/styled/PwaRegistration.tsx +58 -0
  204. package/src/ui/styled/ReactionBar.tsx +93 -0
  205. package/src/ui/styled/ReplayList.tsx +134 -0
  206. package/src/ui/styled/ResetPasswordForm.tsx +127 -0
  207. package/src/ui/styled/ReviewForm.tsx +292 -0
  208. package/src/ui/styled/ReviewsSection.tsx +260 -0
  209. package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
  210. package/src/ui/styled/SignupForm.tsx +109 -0
  211. package/src/ui/styled/UserMenu.tsx +166 -0
  212. package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
  213. package/src/ui/styled/chat/ChatComposer.tsx +153 -0
  214. package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
  215. package/src/ui/styled/chat/MessageBubble.tsx +66 -0
  216. package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
  217. package/src/ui/styled/community/CommunityComposer.tsx +358 -0
  218. package/src/ui/styled/community/CommunityFeed.tsx +483 -0
  219. package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
  220. package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
  221. package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
  222. package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
  223. package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
  224. package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
  225. package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
  226. package/src/ui/styled/community/CommunityRichText.tsx +157 -0
  227. package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
  228. package/src/ui/styled/community/MemberAvatar.tsx +74 -0
  229. package/src/ui/styled/community/index.ts +23 -0
  230. package/src/ui/styled/community/util.ts +15 -0
  231. package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
  232. package/src/ui/styled/members/MemberPostCard.tsx +392 -0
  233. package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
  234. package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
  235. package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
  236. package/src/ui/styled/members/index.ts +7 -0
  237. package/src/ui/styled/rich-text.css +150 -0
  238. package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
  239. package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
  240. package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
  241. package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
  242. package/src/ui/styled/work/WorkReportBody.tsx +327 -0
  243. package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
  244. package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
  245. package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
  246. package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
  247. package/src/ui/styled/work/index.ts +10 -0
  248. package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
  249. package/src/ui/theme/contrast.ts +22 -0
  250. package/src/utils/analyticsBus.ts +31 -0
  251. package/src/utils/attribution.ts +68 -0
  252. package/src/utils/cookieConsent.ts +83 -0
  253. package/src/utils/landing.ts +153 -0
  254. package/src/utils/metaPixel.ts +122 -0
  255. package/src/utils/structuredData.ts +126 -0
@@ -0,0 +1,327 @@
1
+ import { CheckCircle2, ChevronRight, Circle, Clock, Flag } from "lucide-react";
2
+ import { useThemeTokens, type ResolvedThemeTokens } from "../../theme/ForgeThemeProvider";
3
+ import {
4
+ formatWorkDate,
5
+ formatWorkMoney,
6
+ type WorkProjectReportData,
7
+ } from "../../headless/work/useWorkPortal";
8
+
9
+ /**
10
+ * Shared read-only renderer for a project's report — used by both the authed
11
+ * `WorkProjectReport` and the public tokenized `WorkTokenReport`. Shows
12
+ * milestones, a simple task list, and the billable-only time report
13
+ * (hours + amount per task + total). Theme tokens only.
14
+ */
15
+ export interface WorkReportBodyProps {
16
+ report: WorkProjectReportData;
17
+ /** Build the href for a task's detail/comments page. When provided (authed
18
+ * report only), each task row becomes a link. The public token report omits
19
+ * this, so tasks stay read-only there. */
20
+ taskHref?: (taskId: string) => string;
21
+ /** Router-driven open handler. When provided, task rows render as buttons
22
+ * calling this instead of anchors. Takes precedence over `taskHref`. */
23
+ onOpenTask?: (taskId: string) => void;
24
+ }
25
+
26
+ export function WorkReportBody({ report, taskHref, onOpenTask }: WorkReportBodyProps) {
27
+ const t = useThemeTokens();
28
+ const { project, milestones, tasks, timeReport } = report;
29
+ const taskOpenable = !!taskHref || !!onOpenTask;
30
+
31
+ return (
32
+ <div
33
+ data-testid="work-project-report"
34
+ style={{ display: "flex", flexDirection: "column", gap: 24, color: t.text, fontFamily: t.fontFamily }}
35
+ >
36
+ {/* Header */}
37
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>
38
+ <h1 data-testid="work-project-name" style={{ fontSize: 22, fontWeight: 800, margin: 0, color: t.text }}>
39
+ {project.name}
40
+ </h1>
41
+ <StatusPill status={project.status} t={t} />
42
+ </div>
43
+
44
+ {/* Milestones */}
45
+ <Panel title="Milestones" icon={<Flag size={16} />} t={t}>
46
+ {milestones.length === 0 ? (
47
+ <Muted t={t}>No milestones yet.</Muted>
48
+ ) : (
49
+ <ul style={listStyle}>
50
+ {milestones.map((m) => (
51
+ <li key={m.id} style={rowStyle}>
52
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
53
+ <StatusDot status={m.status} t={t} />
54
+ <span style={{ color: t.text }}>{m.name}</span>
55
+ </span>
56
+ <span style={{ fontSize: 13, color: t.muted }}>{formatWorkDate(m.dueDate)}</span>
57
+ </li>
58
+ ))}
59
+ </ul>
60
+ )}
61
+ </Panel>
62
+
63
+ {/* Task list */}
64
+ <Panel title="Tasks" icon={<CheckCircle2 size={16} />} t={t}>
65
+ {tasks.length === 0 ? (
66
+ <Muted t={t}>No tasks yet.</Muted>
67
+ ) : (
68
+ <ul style={listStyle}>
69
+ {tasks.map((task) => {
70
+ const inner = (
71
+ <>
72
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
73
+ {task.done ? (
74
+ <CheckCircle2 size={16} style={{ color: t.primary }} />
75
+ ) : (
76
+ <Circle size={16} style={{ color: t.muted }} />
77
+ )}
78
+ <span
79
+ style={{
80
+ color: task.done ? t.muted : t.text,
81
+ textDecoration: task.done ? "line-through" : "none",
82
+ }}
83
+ >
84
+ {task.title}
85
+ </span>
86
+ </span>
87
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
88
+ <span style={{ fontSize: 13, color: t.muted }}>{formatWorkDate(task.dueDate)}</span>
89
+ {taskOpenable && <ChevronRight size={16} style={{ color: t.muted, flexShrink: 0 }} />}
90
+ </span>
91
+ </>
92
+ );
93
+
94
+ if (!taskOpenable) {
95
+ return (
96
+ <li key={task.id} style={rowStyle}>
97
+ {inner}
98
+ </li>
99
+ );
100
+ }
101
+
102
+ const linkStyle: React.CSSProperties = {
103
+ ...rowStyle,
104
+ width: "100%",
105
+ textAlign: "left",
106
+ cursor: "pointer",
107
+ background: "transparent",
108
+ border: "none",
109
+ padding: "4px 0",
110
+ color: t.text,
111
+ fontFamily: t.fontFamily,
112
+ fontSize: "inherit",
113
+ textDecoration: "none",
114
+ };
115
+
116
+ if (onOpenTask) {
117
+ return (
118
+ <li key={task.id} style={{ listStyle: "none" }}>
119
+ <button type="button" data-testid="work-task-link" onClick={() => onOpenTask(task.id)} style={linkStyle}>
120
+ {inner}
121
+ </button>
122
+ </li>
123
+ );
124
+ }
125
+
126
+ return (
127
+ <li key={task.id} style={{ listStyle: "none" }}>
128
+ <a data-testid="work-task-link" href={taskHref!(task.id)} style={linkStyle}>
129
+ {inner}
130
+ </a>
131
+ </li>
132
+ );
133
+ })}
134
+ </ul>
135
+ )}
136
+ </Panel>
137
+
138
+ {/* Billable time report */}
139
+ <Panel title="Billable time" icon={<Clock size={16} />} t={t}>
140
+ {timeReport.byTask.length === 0 ? (
141
+ <Muted t={t}>No billable time logged yet.</Muted>
142
+ ) : (
143
+ <div data-testid="work-time-report" style={{ width: "100%", overflowX: "auto" }}>
144
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
145
+ <thead>
146
+ <tr>
147
+ <Th align="left" t={t}>
148
+ Task
149
+ </Th>
150
+ <Th align="right" t={t}>
151
+ Hours
152
+ </Th>
153
+ <Th align="right" t={t}>
154
+ Amount
155
+ </Th>
156
+ </tr>
157
+ </thead>
158
+ <tbody>
159
+ {timeReport.byTask.map((row) => (
160
+ <tr key={row.taskId ?? "unassigned"}>
161
+ <Td align="left" t={t}>
162
+ {row.taskTitle}
163
+ </Td>
164
+ <Td align="right" t={t}>
165
+ {row.hours.toLocaleString()}
166
+ </Td>
167
+ <Td align="right" t={t}>
168
+ {formatWorkMoney(row.amount, timeReport.currency)}
169
+ </Td>
170
+ </tr>
171
+ ))}
172
+ </tbody>
173
+ <tfoot>
174
+ <tr data-testid="work-time-total">
175
+ <Td align="left" bold t={t}>
176
+ Total
177
+ </Td>
178
+ <Td align="right" bold t={t}>
179
+ {timeReport.totalHours.toLocaleString()}
180
+ </Td>
181
+ <Td align="right" bold t={t} testId="work-total-amount">
182
+ {formatWorkMoney(timeReport.totalAmount, timeReport.currency)}
183
+ </Td>
184
+ </tr>
185
+ </tfoot>
186
+ </table>
187
+ </div>
188
+ )}
189
+ </Panel>
190
+ </div>
191
+ );
192
+ }
193
+
194
+ const listStyle: React.CSSProperties = {
195
+ margin: 0,
196
+ padding: 0,
197
+ listStyle: "none",
198
+ display: "flex",
199
+ flexDirection: "column",
200
+ gap: 8,
201
+ };
202
+
203
+ const rowStyle: React.CSSProperties = {
204
+ display: "flex",
205
+ alignItems: "center",
206
+ justifyContent: "space-between",
207
+ gap: 12,
208
+ flexWrap: "wrap",
209
+ };
210
+
211
+ function Panel({
212
+ title,
213
+ icon,
214
+ t,
215
+ children,
216
+ }: {
217
+ title: string;
218
+ icon?: React.ReactNode;
219
+ t: ResolvedThemeTokens;
220
+ children: React.ReactNode;
221
+ }) {
222
+ return (
223
+ <div style={{ padding: 20, border: `1px solid ${t.border}`, borderRadius: t.cornerRadius, background: t.surface }}>
224
+ <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
225
+ {icon && <span style={{ color: t.primary, display: "inline-flex" }}>{icon}</span>}
226
+ <h2
227
+ style={{
228
+ fontSize: 13,
229
+ fontWeight: 600,
230
+ textTransform: "uppercase",
231
+ letterSpacing: "0.05em",
232
+ margin: 0,
233
+ color: t.text,
234
+ }}
235
+ >
236
+ {title}
237
+ </h2>
238
+ </div>
239
+ {children}
240
+ </div>
241
+ );
242
+ }
243
+
244
+ function Muted({ t, children }: { t: ResolvedThemeTokens; children: React.ReactNode }) {
245
+ return <p style={{ margin: 0, fontSize: 14, color: t.muted }}>{children}</p>;
246
+ }
247
+
248
+ function StatusPill({ status, t }: { status: string; t: ResolvedThemeTokens }) {
249
+ return (
250
+ <span
251
+ style={{
252
+ fontSize: 12,
253
+ fontWeight: 600,
254
+ padding: "4px 10px",
255
+ borderRadius: 999,
256
+ textTransform: "capitalize",
257
+ background: t.border,
258
+ color: t.primary,
259
+ }}
260
+ >
261
+ {status.replace(/_/g, " ")}
262
+ </span>
263
+ );
264
+ }
265
+
266
+ function StatusDot({ status, t }: { status: string; t: ResolvedThemeTokens }) {
267
+ const done = ["done", "completed", "complete"].includes(status?.toLowerCase());
268
+ return (
269
+ <span
270
+ style={{
271
+ display: "inline-block",
272
+ width: 8,
273
+ height: 8,
274
+ borderRadius: "50%",
275
+ background: done ? t.primary : t.muted,
276
+ }}
277
+ />
278
+ );
279
+ }
280
+
281
+ function Th({ align, t, children }: { align: "left" | "right"; t: ResolvedThemeTokens; children: React.ReactNode }) {
282
+ return (
283
+ <th
284
+ style={{
285
+ padding: "8px",
286
+ fontWeight: 600,
287
+ fontSize: 12,
288
+ textTransform: "uppercase",
289
+ letterSpacing: "0.05em",
290
+ textAlign: align,
291
+ color: t.muted,
292
+ borderBottom: `1px solid ${t.border}`,
293
+ }}
294
+ >
295
+ {children}
296
+ </th>
297
+ );
298
+ }
299
+
300
+ function Td({
301
+ align,
302
+ bold,
303
+ t,
304
+ testId,
305
+ children,
306
+ }: {
307
+ align: "left" | "right";
308
+ bold?: boolean;
309
+ t: ResolvedThemeTokens;
310
+ testId?: string;
311
+ children: React.ReactNode;
312
+ }) {
313
+ return (
314
+ <td
315
+ data-testid={testId}
316
+ style={{
317
+ padding: "8px",
318
+ textAlign: align,
319
+ color: t.text,
320
+ fontWeight: bold ? 700 : 400,
321
+ borderTop: `1px solid ${t.border}`,
322
+ }}
323
+ >
324
+ {children}
325
+ </td>
326
+ );
327
+ }
@@ -0,0 +1,67 @@
1
+ import { Paperclip } from "lucide-react";
2
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
3
+ import { useWorkTaskAttachments } from "../../headless/work/useWorkPortal";
4
+
5
+ export interface WorkTaskAttachmentsProps {
6
+ /** The client project id. */
7
+ projectId: string;
8
+ /** The task id. */
9
+ taskId: string;
10
+ }
11
+
12
+ const humanSize = (size: number | null): string => {
13
+ if (!size || size <= 0) return "";
14
+ const units = ["B", "KB", "MB", "GB"];
15
+ let n = size;
16
+ let i = 0;
17
+ while (n >= 1024 && i < units.length - 1) {
18
+ n /= 1024;
19
+ i += 1;
20
+ }
21
+ return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
22
+ };
23
+
24
+ /**
25
+ * Read-only list of files the team attached to a task, for the client portal.
26
+ * Built on the `useWorkTaskAttachments` Tier-1 primitive. Theme tokens only.
27
+ */
28
+ export function WorkTaskAttachments({ projectId, taskId }: WorkTaskAttachmentsProps) {
29
+ const t = useThemeTokens();
30
+ const { data: attachments, isLoading } = useWorkTaskAttachments(projectId, taskId);
31
+
32
+ if (isLoading) return null;
33
+ if (!attachments || attachments.length === 0) return null;
34
+
35
+ return (
36
+ <div data-testid="work-task-attachments" style={{ display: "flex", flexDirection: "column", gap: 10 }}>
37
+ <span style={{ fontSize: 13, fontWeight: 700, color: t.text }}>Attachments</span>
38
+ <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
39
+ {attachments.map((att) => (
40
+ <li key={att.id} data-testid={`work-task-attachment-${att.id}`}>
41
+ <a
42
+ href={att.url ?? "#"}
43
+ target="_blank"
44
+ rel="noreferrer"
45
+ style={{
46
+ display: "inline-flex",
47
+ alignItems: "center",
48
+ gap: 8,
49
+ fontSize: 13,
50
+ color: t.primary,
51
+ textDecoration: "none",
52
+ padding: "8px 12px",
53
+ border: `1px solid ${t.border}`,
54
+ borderRadius: t.cornerRadius,
55
+ background: t.surface,
56
+ }}
57
+ >
58
+ <Paperclip size={14} />
59
+ <span>{att.name || att.filename || "Attachment"}</span>
60
+ {att.size ? <span style={{ color: t.muted }}>({humanSize(att.size)})</span> : null}
61
+ </a>
62
+ </li>
63
+ ))}
64
+ </ul>
65
+ </div>
66
+ );
67
+ }
@@ -0,0 +1,151 @@
1
+ import { useState, type FormEvent } from "react";
2
+ import { MessageSquare, Send } from "lucide-react";
3
+ import { useThemeTokens, type ResolvedThemeTokens } from "../../theme/ForgeThemeProvider";
4
+ import { Loading } from "../Loading";
5
+ import {
6
+ formatWorkDate,
7
+ useWorkTaskComments,
8
+ type WorkTaskComment,
9
+ } from "../../headless/work/useWorkPortal";
10
+
11
+ export interface WorkTaskCommentsProps {
12
+ /** The client project id (from the route). */
13
+ projectId: string;
14
+ /** The task id whose thread is shown. */
15
+ taskId: string;
16
+ }
17
+
18
+ /**
19
+ * The authenticated client's comment thread for a single task — renders the
20
+ * existing comments (author + time + content, newest first) and a composer
21
+ * (textarea + submit) that posts a new one. Built on `useWorkTaskComments`;
22
+ * theme tokens only.
23
+ */
24
+ export function WorkTaskComments({ projectId, taskId }: WorkTaskCommentsProps) {
25
+ const t = useThemeTokens();
26
+ const { comments, isLoading, error, addComment, isAdding } = useWorkTaskComments(projectId, taskId);
27
+ const [content, setContent] = useState("");
28
+
29
+ const handleSubmit = async (e: FormEvent) => {
30
+ e.preventDefault();
31
+ const trimmed = content.trim();
32
+ if (!trimmed || isAdding) return;
33
+ try {
34
+ await addComment(trimmed);
35
+ setContent("");
36
+ } catch {
37
+ // Keep the draft so the client can retry; the composer stays populated.
38
+ }
39
+ };
40
+
41
+ return (
42
+ <div
43
+ data-testid="work-task-comments"
44
+ style={{ display: "flex", flexDirection: "column", gap: 16, color: t.text, fontFamily: t.fontFamily }}
45
+ >
46
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
47
+ <MessageSquare size={16} style={{ color: t.primary }} />
48
+ <h2
49
+ style={{
50
+ fontSize: 13,
51
+ fontWeight: 600,
52
+ textTransform: "uppercase",
53
+ letterSpacing: "0.05em",
54
+ margin: 0,
55
+ color: t.text,
56
+ }}
57
+ >
58
+ Comments
59
+ </h2>
60
+ </div>
61
+
62
+ {isLoading ? (
63
+ <Loading />
64
+ ) : error ? (
65
+ <p style={{ margin: 0, fontSize: 14, color: t.muted }}>We couldn&apos;t load comments right now.</p>
66
+ ) : comments.length === 0 ? (
67
+ <p style={{ margin: 0, fontSize: 14, color: t.muted }}>No comments yet. Start the conversation below.</p>
68
+ ) : (
69
+ <ul style={{ margin: 0, padding: 0, listStyle: "none", display: "flex", flexDirection: "column", gap: 12 }}>
70
+ {comments.map((c) => (
71
+ <CommentItem key={c.id} comment={c} t={t} />
72
+ ))}
73
+ </ul>
74
+ )}
75
+
76
+ {/* Composer */}
77
+ <form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
78
+ <textarea
79
+ data-testid="work-task-comment-input"
80
+ value={content}
81
+ onChange={(e) => setContent(e.target.value)}
82
+ placeholder="Add a comment…"
83
+ rows={3}
84
+ disabled={isAdding}
85
+ style={{
86
+ width: "100%",
87
+ resize: "vertical",
88
+ padding: "10px 12px",
89
+ fontFamily: t.fontFamily,
90
+ fontSize: 14,
91
+ color: t.text,
92
+ background: t.background,
93
+ border: `1px solid ${t.border}`,
94
+ borderRadius: t.cornerRadius,
95
+ boxSizing: "border-box",
96
+ }}
97
+ />
98
+ <div style={{ display: "flex", justifyContent: "flex-end" }}>
99
+ <button
100
+ type="submit"
101
+ data-testid="work-task-comment-submit"
102
+ disabled={isAdding || content.trim().length === 0}
103
+ style={{
104
+ display: "inline-flex",
105
+ alignItems: "center",
106
+ gap: 8,
107
+ padding: "8px 16px",
108
+ fontFamily: t.fontFamily,
109
+ fontSize: 14,
110
+ fontWeight: 600,
111
+ color: t.textPrimary,
112
+ background: t.primary,
113
+ border: "none",
114
+ borderRadius: t.cornerRadius,
115
+ cursor: isAdding || content.trim().length === 0 ? "not-allowed" : "pointer",
116
+ opacity: isAdding || content.trim().length === 0 ? 0.6 : 1,
117
+ }}
118
+ >
119
+ <Send size={14} />
120
+ {isAdding ? "Posting…" : "Post comment"}
121
+ </button>
122
+ </div>
123
+ </form>
124
+ </div>
125
+ );
126
+ }
127
+
128
+ function CommentItem({ comment, t }: { comment: WorkTaskComment; t: ResolvedThemeTokens }) {
129
+ return (
130
+ <li
131
+ data-testid="work-task-comment-item"
132
+ style={{
133
+ padding: 14,
134
+ border: `1px solid ${t.border}`,
135
+ borderRadius: t.cornerRadius,
136
+ background: t.surface,
137
+ display: "flex",
138
+ flexDirection: "column",
139
+ gap: 6,
140
+ }}
141
+ >
142
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, flexWrap: "wrap" }}>
143
+ <span style={{ fontWeight: 600, fontSize: 14, color: t.text }}>{comment.fullName}</span>
144
+ <span style={{ fontSize: 12, color: t.muted }}>{formatWorkDate(comment.createdAt)}</span>
145
+ </div>
146
+ <p style={{ margin: 0, fontSize: 14, color: t.text, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
147
+ {comment.content}
148
+ </p>
149
+ </li>
150
+ );
151
+ }
@@ -0,0 +1,92 @@
1
+ import { ArrowLeft, CheckCircle2, Circle } from "lucide-react";
2
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
3
+ import { Loading } from "../Loading";
4
+ import { formatWorkDate, useWorkProjectReport } from "../../headless/work/useWorkPortal";
5
+ import { WorkTaskComments } from "./WorkTaskComments";
6
+ import { WorkTaskAttachments } from "./WorkTaskAttachments";
7
+
8
+ export interface WorkTaskDetailProps {
9
+ /** The client project id (from the route). */
10
+ projectId: string;
11
+ /** The task id (from the route). */
12
+ taskId: string;
13
+ /** Href back to the project report. Rendered as an `<a>` unless `onBack` is
14
+ * supplied. Defaults to `/i/work/projects/{projectId}`. */
15
+ backHref?: string;
16
+ /** Router-driven back handler. When provided, the back link renders as a
17
+ * button calling this instead of an anchor. */
18
+ onBack?: () => void;
19
+ }
20
+
21
+ /**
22
+ * The authenticated client's single-task detail — the task title/status plus
23
+ * the comment thread (`WorkTaskComments`). Resolves the task from the project
24
+ * report so it shows a friendly title. Theme tokens only.
25
+ */
26
+ export function WorkTaskDetail({ projectId, taskId, backHref, onBack }: WorkTaskDetailProps) {
27
+ const t = useThemeTokens();
28
+ const { data: report, isLoading } = useWorkProjectReport(projectId);
29
+ const href = backHref ?? `/i/work/projects/${projectId}`;
30
+
31
+ const task = report?.tasks.find((tk) => tk.id === taskId);
32
+
33
+ const backLinkStyle: React.CSSProperties = {
34
+ display: "inline-flex",
35
+ alignItems: "center",
36
+ gap: 6,
37
+ fontSize: 13,
38
+ fontWeight: 600,
39
+ color: t.primary,
40
+ background: "transparent",
41
+ border: "none",
42
+ padding: 0,
43
+ cursor: "pointer",
44
+ textDecoration: "none",
45
+ };
46
+
47
+ const backLink = onBack ? (
48
+ <button type="button" data-testid="work-task-back" onClick={onBack} style={backLinkStyle}>
49
+ <ArrowLeft size={14} /> Back to project
50
+ </button>
51
+ ) : (
52
+ <a data-testid="work-task-back" href={href} style={backLinkStyle}>
53
+ <ArrowLeft size={14} /> Back to project
54
+ </a>
55
+ );
56
+
57
+ return (
58
+ <div
59
+ data-testid="work-task-detail"
60
+ style={{ display: "flex", flexDirection: "column", gap: 24, color: t.text, fontFamily: t.fontFamily }}
61
+ >
62
+ {backLink}
63
+
64
+ {isLoading ? (
65
+ <Loading fullPage />
66
+ ) : (
67
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
68
+ <h1
69
+ data-testid="work-task-title"
70
+ style={{ fontSize: 22, fontWeight: 800, margin: 0, color: t.text, display: "inline-flex", alignItems: "center", gap: 10 }}
71
+ >
72
+ {task ? (
73
+ task.done ? (
74
+ <CheckCircle2 size={20} style={{ color: t.primary }} />
75
+ ) : (
76
+ <Circle size={20} style={{ color: t.muted }} />
77
+ )
78
+ ) : null}
79
+ {task ? task.title : "Task"}
80
+ </h1>
81
+ {task?.dueDate && <span style={{ fontSize: 13, color: t.muted }}>Due {formatWorkDate(task.dueDate)}</span>}
82
+ </div>
83
+ )}
84
+
85
+ <WorkTaskAttachments projectId={projectId} taskId={taskId} />
86
+
87
+ <div style={{ padding: 20, border: `1px solid ${t.border}`, borderRadius: t.cornerRadius, background: t.surface }}>
88
+ <WorkTaskComments projectId={projectId} taskId={taskId} />
89
+ </div>
90
+ </div>
91
+ );
92
+ }
@@ -0,0 +1,49 @@
1
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
2
+ import { Loading } from "../Loading";
3
+ import { useWorkTokenReport } from "../../headless/work/useWorkPortal";
4
+ import { WorkReportBody } from "./WorkReportBody";
5
+
6
+ export interface WorkTokenReportProps {
7
+ /** Signed share token from the URL (`/i/work/time-report/:token`). */
8
+ token: string;
9
+ }
10
+
11
+ /**
12
+ * Public, login-free tokenized share of a project's time report. The signed
13
+ * token IS the authorization — no auth guard. Invalid/expired tokens resolve to
14
+ * a friendly not-found notice (the API returns 404). Built on
15
+ * `useWorkTokenReport`; wraps `WorkReportBody`. Theme tokens only.
16
+ */
17
+ export function WorkTokenReport({ token }: WorkTokenReportProps) {
18
+ const t = useThemeTokens();
19
+ const { data: report, isLoading, isError } = useWorkTokenReport(token);
20
+
21
+ if (isLoading) {
22
+ return <Loading fullPage />;
23
+ }
24
+
25
+ if (isError || !report) {
26
+ return (
27
+ <div
28
+ data-testid="work-token-report-error"
29
+ style={{
30
+ border: `1px solid ${t.border}`,
31
+ borderRadius: t.cornerRadius,
32
+ background: t.surface,
33
+ padding: 32,
34
+ textAlign: "center",
35
+ color: t.text,
36
+ fontFamily: t.fontFamily,
37
+ }}
38
+ >
39
+ <p style={{ margin: 0, color: t.muted }}>This report link is invalid or has expired.</p>
40
+ </div>
41
+ );
42
+ }
43
+
44
+ return (
45
+ <div data-testid="work-token-report">
46
+ <WorkReportBody report={report} />
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,10 @@
1
+ // Work (project management) client-portal styled blocks.
2
+ export { WorkPortalProjects, type WorkPortalProjectsProps } from "./WorkPortalProjects";
3
+ export { WorkProjectReport, type WorkProjectReportProps } from "./WorkProjectReport";
4
+ export { WorkTokenReport, type WorkTokenReportProps } from "./WorkTokenReport";
5
+ export { WorkReportBody, type WorkReportBodyProps } from "./WorkReportBody";
6
+ export { WorkTaskComments, type WorkTaskCommentsProps } from "./WorkTaskComments";
7
+ export { WorkTaskAttachments, type WorkTaskAttachmentsProps } from "./WorkTaskAttachments";
8
+ export { WorkTaskDetail, type WorkTaskDetailProps } from "./WorkTaskDetail";
9
+ export { WorkInviteAccept, type WorkInviteAcceptProps } from "./WorkInviteAccept";
10
+ export { WorkProjectInvoices, type WorkProjectInvoicesProps } from "./WorkProjectInvoices";