@tribe-nest/forge 3.59.0 → 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/package.json +2 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CartContext.tsx +34 -1
  4. package/src/contexts/CommunityContext.tsx +192 -0
  5. package/src/contexts/PublicAuthContext.tsx +15 -3
  6. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  7. package/src/data/queries/useAccount.ts +33 -2
  8. package/src/data/queries/useAnalytics.ts +6 -6
  9. package/src/data/queries/useCallChat.ts +2 -2
  10. package/src/data/queries/useChat.ts +52 -40
  11. package/src/data/queries/useCheckouts.ts +2 -1
  12. package/src/data/queries/useCoachingAvailability.ts +10 -1
  13. package/src/data/queries/useCommunities.ts +345 -0
  14. package/src/data/queries/useCommunity.ts +450 -102
  15. package/src/data/queries/useCommunityAgent.ts +247 -0
  16. package/src/data/queries/useCommunityCalendar.ts +150 -0
  17. package/src/data/queries/useCommunityClassroom.ts +75 -0
  18. package/src/data/queries/useCommunityMembers.ts +108 -0
  19. package/src/data/queries/useCommunityModeration.ts +327 -0
  20. package/src/data/queries/useCommunityWiki.ts +137 -0
  21. package/src/data/queries/useCourses.ts +2 -0
  22. package/src/data/queries/useEmailSignIn.ts +79 -0
  23. package/src/data/queries/useEvents.ts +6 -1
  24. package/src/data/queries/useForms.ts +5 -1
  25. package/src/data/queries/useLeadMagnet.ts +6 -2
  26. package/src/data/queries/useMembership.ts +32 -7
  27. package/src/data/queries/useMembershipCalls.ts +116 -7
  28. package/src/data/queries/useMyBookings.ts +24 -1
  29. package/src/data/queries/useMyTickets.ts +24 -1
  30. package/src/data/queries/useNotifications.ts +6 -2
  31. package/src/data/queries/useOrders.ts +2 -1
  32. package/src/data/queries/usePWA.ts +111 -35
  33. package/src/data/queries/usePostCollections.ts +28 -16
  34. package/src/data/queries/usePosts.ts +16 -14
  35. package/src/data/queries/useSiteApp.ts +130 -0
  36. package/src/data/queries/useSubscriptions.ts +28 -9
  37. package/src/i18n/de.json +891 -2
  38. package/src/i18n/en.json +891 -2
  39. package/src/index.ts +36 -7
  40. package/src/runtime/pages.spec.ts +36 -0
  41. package/src/runtime/pages.ts +52 -27
  42. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  43. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  44. package/src/types/models.ts +11 -0
  45. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  46. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  47. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  48. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  49. package/src/ui/analytics/useMetaEvents.ts +78 -0
  50. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  51. package/src/ui/format/membershipGate.ts +19 -15
  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/communityGate.spec.tsx +80 -0
  56. package/src/ui/headless/_tests/communityHarness.tsx +212 -0
  57. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  58. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  59. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  60. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  61. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  62. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  63. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  64. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  65. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  66. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
  67. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
  68. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  69. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
  70. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  71. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  72. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  73. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  74. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  75. package/src/ui/headless/auth/useSignupForm.ts +30 -3
  76. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  77. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  78. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  79. package/src/ui/headless/communities/index.ts +96 -0
  80. package/src/ui/headless/communities/paths.ts +36 -0
  81. package/src/ui/headless/communities/useCommunity.ts +140 -0
  82. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  83. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  84. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  85. package/src/ui/headless/communities/useCommunityHome.ts +91 -0
  86. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  87. package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
  88. package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
  89. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  90. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  91. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  92. package/src/ui/headless/community/communityPoll.ts +297 -0
  93. package/src/ui/headless/community/countries.ts +123 -0
  94. package/src/ui/headless/community/index.ts +81 -1
  95. package/src/ui/headless/community/linkPreview.ts +71 -0
  96. package/src/ui/headless/community/richLexical.ts +27 -2
  97. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  98. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  99. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  100. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  101. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  102. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  103. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  104. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  105. package/src/ui/headless/downloads/index.ts +8 -0
  106. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  107. package/src/ui/headless/index.ts +21 -4
  108. package/src/ui/headless/memberHome/index.ts +11 -1
  109. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  110. package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
  111. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  112. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  113. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  114. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  115. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  116. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  117. package/src/ui/headless/portal/index.ts +57 -0
  118. package/src/ui/headless/portal/usePortal.ts +274 -0
  119. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  120. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  121. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  122. package/src/ui/headless/posts/upsell.ts +54 -8
  123. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  124. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  125. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  126. package/src/ui/headless/posts/usePostItem.ts +6 -5
  127. package/src/ui/headless/siteApp/appBar.ts +102 -0
  128. package/src/ui/headless/siteApp/appHome.ts +344 -0
  129. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  130. package/src/ui/headless/siteApp/index.ts +58 -0
  131. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  132. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  133. package/src/ui/headless/work/index.ts +2 -0
  134. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  135. package/src/ui/index.ts +28 -12
  136. package/src/ui/styled/AccountDashboard.tsx +754 -602
  137. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  138. package/src/ui/styled/ChatRoom.tsx +126 -16
  139. package/src/ui/styled/Checkout.tsx +8 -0
  140. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  141. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  142. package/src/ui/styled/CoachingDetail.tsx +2 -0
  143. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  144. package/src/ui/styled/CourseDetail.tsx +2 -0
  145. package/src/ui/styled/DownloadPage.tsx +263 -0
  146. package/src/ui/styled/EventConfirmation.tsx +10 -0
  147. package/src/ui/styled/EventDetail.tsx +10 -0
  148. package/src/ui/styled/FilmLibrary.tsx +22 -9
  149. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  150. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  151. package/src/ui/styled/MembershipTiers.tsx +49 -11
  152. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  153. package/src/ui/styled/Paywall.tsx +1 -1
  154. package/src/ui/styled/ProductDetail.tsx +12 -0
  155. package/src/ui/styled/PwaRegistration.tsx +5 -1
  156. package/src/ui/styled/SignupForm.tsx +18 -3
  157. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
  158. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  159. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  160. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  161. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  162. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  163. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  164. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  165. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  166. package/src/ui/styled/app/AppBar.tsx +298 -0
  167. package/src/ui/styled/app/AppHome.tsx +1139 -0
  168. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  169. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  170. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  171. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  172. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  173. package/src/ui/styled/app/index.ts +7 -0
  174. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  175. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  176. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  177. package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
  178. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  179. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  180. package/src/ui/styled/communities/CommunityHome.tsx +298 -0
  181. package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
  182. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
  183. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  184. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  185. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  186. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  187. package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
  188. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  189. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  190. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  191. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  192. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  193. package/src/ui/styled/communities/index.ts +19 -0
  194. package/src/ui/styled/communities/shared.tsx +177 -0
  195. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  196. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  197. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  198. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  199. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  200. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  201. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  202. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  203. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  204. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  205. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  206. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  207. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  208. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  209. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  210. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  211. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  212. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  213. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  214. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  215. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  216. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  217. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  218. package/src/ui/styled/community/index.ts +37 -2
  219. package/src/ui/styled/forge-utilities.css +253 -0
  220. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  221. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  222. package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
  223. package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
  224. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  225. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  226. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  227. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  228. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  229. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  230. package/src/ui/styled/members/index.ts +8 -0
  231. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  232. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  233. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  234. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  235. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  236. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  237. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  238. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  239. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  240. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  241. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  242. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  243. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  244. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  245. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  246. package/src/ui/styled/portal/index.ts +13 -0
  247. package/src/ui/styled/portal/portalNav.ts +260 -0
  248. package/src/ui/styled/portal/shared.tsx +206 -0
  249. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  250. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  251. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  252. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  253. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  254. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  255. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  256. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  257. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  258. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  259. package/src/ui/styled/work/index.ts +1 -1
  260. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  261. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  262. package/src/utils/landing.ts +19 -6
  263. package/src/utils/metaEvents.ts +302 -0
  264. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  265. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  266. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
@@ -0,0 +1,197 @@
1
+ import { useState } from "react";
2
+ import { useForgeT } from "../../../i18n";
3
+ import { internalPath } from "../../../runtime/pages";
4
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
5
+ import { usePortalDocuments, usePortalInvoices, type PortalInvoice } from "../../headless/portal/usePortal";
6
+ import { Loading } from "../Loading";
7
+ import { PortalEmpty, PortalHeading, PortalLink, PortalList, PortalRow, portalMoney, usePortal, usePortalButtons } from "./shared";
8
+
9
+ /** An invoice still owed. Paid, cancelled and refunded ones are history. */
10
+ const OPEN_STATUSES = new Set(["sent", "overdue", "initiated_payment", "payment_failed", "viewed"]);
11
+
12
+ /**
13
+ * Every invoice the creator has sent this person (`/i/account/invoices`).
14
+ *
15
+ * Owed invoices first, with the amount and the due date on the row and a Pay
16
+ * button; settled ones below, read-only. Each opens the hosted invoice page,
17
+ * which is where paying and the PDF already live.
18
+ */
19
+ export function PortalInvoices() {
20
+ const tr = useForgeT();
21
+ const t = useThemeTokens();
22
+ const [filter, setFilter] = useState<"open" | "all">("open");
23
+ const { data, isLoading } = usePortalInvoices("all");
24
+ const invoices = data?.data ?? [];
25
+ const open = invoices.filter((inv) => OPEN_STATUSES.has(inv.status));
26
+ const shown = filter === "open" ? open : invoices;
27
+
28
+ return (
29
+ <div data-testid="portal-invoices">
30
+ <PortalHeading
31
+ title={tr("forge.portal_billing.invoices_title")}
32
+ lead={
33
+ open.length > 0
34
+ ? tr("forge.portal_billing.invoices_lead_open", { count: open.length })
35
+ : invoices.length > 0
36
+ ? tr("forge.portal_billing.invoices_lead_settled")
37
+ : undefined
38
+ }
39
+ />
40
+ {isLoading ? (
41
+ <Loading />
42
+ ) : invoices.length === 0 ? (
43
+ <PortalEmpty title={tr("forge.portal_billing.invoices_empty_title")} body={tr("forge.portal_billing.invoices_empty_body")} />
44
+ ) : (
45
+ <>
46
+ <div role="tablist" aria-label={tr("forge.portal_billing.filter_label")} className="fg:mb-4 fg:flex fg:gap-5">
47
+ {(["open", "all"] as const).map((key) => (
48
+ <button
49
+ key={key}
50
+ type="button"
51
+ role="tab"
52
+ aria-selected={filter === key}
53
+ onClick={() => setFilter(key)}
54
+ style={{
55
+ padding: "4px 0",
56
+ background: "transparent",
57
+ border: "none",
58
+ borderBottom: `2px solid ${filter === key ? t.primary : "transparent"}`,
59
+ color: filter === key ? t.text : t.muted,
60
+ fontWeight: filter === key ? 600 : 500,
61
+ fontFamily: t.fontFamily,
62
+ fontSize: 14,
63
+ cursor: "pointer",
64
+ }}
65
+ >
66
+ {key === "open"
67
+ ? tr("forge.portal_billing.filter_open", { count: open.length })
68
+ : tr("forge.portal_billing.filter_all", { count: invoices.length })}
69
+ </button>
70
+ ))}
71
+ </div>
72
+ {shown.length === 0 ? (
73
+ <PortalEmpty title={tr("forge.portal_billing.nothing_owed")} />
74
+ ) : (
75
+ <PortalList>
76
+ {shown.map((inv) => (
77
+ <InvoiceRow key={inv.id} invoice={inv} />
78
+ ))}
79
+ </PortalList>
80
+ )}
81
+ </>
82
+ )}
83
+ </div>
84
+ );
85
+ }
86
+
87
+ function InvoiceRow({ invoice }: { invoice: PortalInvoice }) {
88
+ const t = useThemeTokens();
89
+ const tr = useForgeT();
90
+ const { formatDate } = usePortal();
91
+ const buttons = usePortalButtons();
92
+ const owed = OPEN_STATUSES.has(invoice.status);
93
+ const overdue = invoice.status === "overdue";
94
+ // Literal keys, so the translation spec can see every one of them.
95
+ const statusLabel: Record<string, string> = {
96
+ sent: tr("forge.portal_billing.status_sent"),
97
+ viewed: tr("forge.portal_billing.status_sent"),
98
+ overdue: tr("forge.portal_billing.status_overdue"),
99
+ initiated_payment: tr("forge.portal_billing.status_processing"),
100
+ payment_failed: tr("forge.portal_billing.status_failed"),
101
+ paid: tr("forge.portal_billing.status_paid"),
102
+ cancelled: tr("forge.portal_billing.status_cancelled"),
103
+ refunded: tr("forge.portal_billing.status_refunded"),
104
+ };
105
+ const href = internalPath("invoice/$invoiceId", { invoiceId: invoice.id });
106
+
107
+ return (
108
+ <PortalRow testId="portal-invoice">
109
+ <div className="fg:min-w-0 fg:flex-1">
110
+ <p style={{ margin: 0, fontWeight: 600 }}>{invoice.invoiceNumber}</p>
111
+ <p className="fg:flex fg:flex-wrap fg:gap-x-3" style={{ margin: "2px 0 0", fontSize: 13, color: t.muted }}>
112
+ <span style={overdue || invoice.status === "payment_failed" ? { color: t.text, fontWeight: 600 } : undefined}>
113
+ {statusLabel[invoice.status] ?? invoice.status}
114
+ </span>
115
+ {owed && invoice.dueDate ? (
116
+ <span>{tr("forge.portal_billing.due_on", { date: formatDate(invoice.dueDate) })}</span>
117
+ ) : null}
118
+ {invoice.paidAt ? <span>{tr("forge.portal_billing.paid_on", { date: formatDate(invoice.paidAt) })}</span> : null}
119
+ {invoice.projectName ? <span>{invoice.projectName}</span> : null}
120
+ </p>
121
+ </div>
122
+ <span style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
123
+ {portalMoney(invoice.total, invoice.currency)}
124
+ </span>
125
+ <PortalLink href={href} style={owed ? buttons.primary : buttons.quiet}>
126
+ {owed ? tr("forge.portal_billing.pay") : tr("forge.portal_billing.view")}
127
+ </PortalLink>
128
+ </PortalRow>
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Documents sent to this person (`/i/account/documents`): the ones waiting for
134
+ * their signature first, then everything already signed or closed. Each opens
135
+ * the signing page, which keeps its own email check.
136
+ */
137
+ export function PortalDocuments() {
138
+ const tr = useForgeT();
139
+ const t = useThemeTokens();
140
+ const { formatDate } = usePortal();
141
+ const buttons = usePortalButtons();
142
+ const { data: documents, isLoading } = usePortalDocuments();
143
+ const waiting = (documents ?? []).filter((d) => d.needsAction);
144
+ const rest = (documents ?? []).filter((d) => !d.needsAction);
145
+
146
+ const row = (doc: NonNullable<typeof documents>[number]) => (
147
+ <PortalRow key={doc.documentId} testId="portal-document">
148
+ <div className="fg:min-w-0 fg:flex-1">
149
+ <p style={{ margin: 0, fontWeight: 600 }}>{doc.title}</p>
150
+ <p className="fg:flex fg:flex-wrap fg:gap-x-3" style={{ margin: "2px 0 0", fontSize: 13, color: t.muted }}>
151
+ {doc.completedAt ? (
152
+ <span>{tr("forge.portal_billing.signed_on", { date: formatDate(doc.completedAt) })}</span>
153
+ ) : (
154
+ <span>{tr("forge.portal_billing.sent_on", { date: formatDate(doc.sentAt) })}</span>
155
+ )}
156
+ {doc.needsAction && doc.expiresAt ? (
157
+ <span style={{ color: t.text }}>{tr("forge.portal_billing.expires_on", { date: formatDate(doc.expiresAt) })}</span>
158
+ ) : null}
159
+ </p>
160
+ </div>
161
+ <PortalLink
162
+ href={internalPath("documents/$token", { token: doc.token })}
163
+ style={doc.needsAction ? buttons.primary : buttons.quiet}
164
+ >
165
+ {doc.needsAction ? tr("forge.portal_billing.review_and_sign") : tr("forge.portal_billing.open")}
166
+ </PortalLink>
167
+ </PortalRow>
168
+ );
169
+
170
+ return (
171
+ <div data-testid="portal-documents">
172
+ <PortalHeading
173
+ title={tr("forge.portal_billing.documents_title")}
174
+ lead={waiting.length > 0 ? tr("forge.portal_billing.documents_lead_waiting", { count: waiting.length }) : undefined}
175
+ />
176
+ {isLoading ? (
177
+ <Loading />
178
+ ) : !documents || documents.length === 0 ? (
179
+ <PortalEmpty title={tr("forge.portal_billing.documents_empty_title")} body={tr("forge.portal_billing.documents_empty_body")} />
180
+ ) : (
181
+ <>
182
+ {waiting.length > 0 ? <PortalList style={{ marginBottom: 32 }}>{waiting.map(row)}</PortalList> : null}
183
+ {rest.length > 0 ? (
184
+ <>
185
+ {waiting.length > 0 ? (
186
+ <h2 style={{ margin: "0 0 8px", fontSize: 18, fontWeight: 600, fontFamily: t.headingFontFamily }}>
187
+ {tr("forge.portal_billing.documents_done")}
188
+ </h2>
189
+ ) : null}
190
+ <PortalList>{rest.map(row)}</PortalList>
191
+ </>
192
+ ) : null}
193
+ </>
194
+ )}
195
+ </div>
196
+ );
197
+ }
@@ -0,0 +1,279 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { FileSignature, FileText, Paperclip, Receipt, ShoppingBag } from "lucide-react";
3
+ import { useForgeT } from "../../../i18n";
4
+ import { useForge } from "../../../provider/ForgeProvider";
5
+ import { internalPath } from "../../../runtime/pages";
6
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
7
+ import { useWorkOpenSharedDocument } from "../../headless/work/useWorkPortal";
8
+ import {
9
+ usePortalFiles,
10
+ type PortalFile,
11
+ type PortalFileSource,
12
+ type PortalFileSourceFilter,
13
+ } from "../../headless/portal/usePortalFiles";
14
+ import { Loading } from "../Loading";
15
+ import { PortalEmpty, PortalHeading, PortalLink, PortalList, PortalRow, usePortal, usePortalButtons } from "./shared";
16
+
17
+ const FILTERS: PortalFileSourceFilter[] = ["all", "purchase", "project", "task", "document", "invoice"];
18
+
19
+ const ICONS: Record<PortalFileSource, typeof FileText> = {
20
+ project: FileText,
21
+ task: Paperclip,
22
+ document: FileSignature,
23
+ invoice: Receipt,
24
+ purchase: ShoppingBag,
25
+ };
26
+
27
+ /** "1.2 MB". Sizes are bytes; nothing here is worth a locale-aware unit. */
28
+ export function formatFileSize(bytes: number | null): string | null {
29
+ if (bytes == null || !Number.isFinite(bytes) || bytes <= 0) return null;
30
+ const units = ["B", "KB", "MB", "GB"];
31
+ let value = bytes;
32
+ let unit = 0;
33
+ while (value >= 1024 && unit < units.length - 1) {
34
+ value /= 1024;
35
+ unit += 1;
36
+ }
37
+ return `${unit === 0 ? value : value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
38
+ }
39
+
40
+ /**
41
+ * Every file this person has with the creator (`/i/account/files`): files
42
+ * shared on their projects, attachments on their tasks, signed documents,
43
+ * invoice PDFs and the downloads they paid for, newest first, with a filter
44
+ * per kind.
45
+ *
46
+ * Nothing on the page holds an address it was not given for that purpose: a
47
+ * shared project file asks for a 60-second link on the click, and a signed
48
+ * document or invoice downloads through the endpoint that checks it.
49
+ */
50
+ export function PortalFiles() {
51
+ const tr = useForgeT();
52
+ const t = useThemeTokens();
53
+ const buttons = usePortalButtons();
54
+ const [filter, setFilter] = useState<PortalFileSourceFilter>("all");
55
+ const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = usePortalFiles(filter);
56
+ const files = data?.pages.flatMap((p) => p.data) ?? [];
57
+
58
+ // Literal keys, so the translation spec can see every one of them.
59
+ const filterLabel: Record<PortalFileSourceFilter, string> = {
60
+ all: tr("forge.portal_files.filter_all"),
61
+ project: tr("forge.portal_files.filter_project"),
62
+ task: tr("forge.portal_files.filter_task"),
63
+ document: tr("forge.portal_files.filter_document"),
64
+ invoice: tr("forge.portal_files.filter_invoice"),
65
+ purchase: tr("forge.portal_files.filter_purchase"),
66
+ };
67
+
68
+ // With nothing at all, the tabs would only filter an empty list.
69
+ const nothingAtAll = !isLoading && filter === "all" && files.length === 0;
70
+
71
+ return (
72
+ <div data-testid="portal-files">
73
+ <PortalHeading title={tr("forge.portal_files.title")} />
74
+ {nothingAtAll ? (
75
+ <PortalEmpty title={tr("forge.portal_files.empty_title")} body={tr("forge.portal_files.empty_body")} />
76
+ ) : (
77
+ <>
78
+ <div
79
+ role="tablist"
80
+ aria-label={tr("forge.portal_files.filter_label")}
81
+ className="fg:mb-4 fg:flex fg:flex-wrap fg:gap-x-5 fg:gap-y-2"
82
+ >
83
+ {FILTERS.map((key) => (
84
+ <button
85
+ key={key}
86
+ type="button"
87
+ role="tab"
88
+ aria-selected={filter === key}
89
+ data-testid={`portal-files-filter-${key}`}
90
+ onClick={() => setFilter(key)}
91
+ style={{
92
+ padding: "4px 0",
93
+ background: "transparent",
94
+ border: "none",
95
+ borderBottom: `2px solid ${filter === key ? t.primary : "transparent"}`,
96
+ color: filter === key ? t.text : t.muted,
97
+ fontWeight: filter === key ? 600 : 500,
98
+ fontFamily: t.fontFamily,
99
+ fontSize: 14,
100
+ cursor: "pointer",
101
+ }}
102
+ >
103
+ {filterLabel[key]}
104
+ </button>
105
+ ))}
106
+ </div>
107
+ {isLoading ? (
108
+ <Loading />
109
+ ) : files.length === 0 ? (
110
+ <PortalEmpty title={tr("forge.portal_files.filter_empty")} />
111
+ ) : (
112
+ <>
113
+ <PortalList>
114
+ {files.map((file) => (
115
+ <FileRow key={`${file.source}:${file.id}`} file={file} />
116
+ ))}
117
+ </PortalList>
118
+ {hasNextPage ? (
119
+ <div className="fg:mt-6 fg:flex fg:justify-center">
120
+ <button
121
+ type="button"
122
+ data-testid="portal-files-more"
123
+ onClick={() => void fetchNextPage()}
124
+ disabled={isFetchingNextPage}
125
+ style={{ ...buttons.quiet, opacity: isFetchingNextPage ? 0.6 : 1 }}
126
+ >
127
+ {tr("forge.portal_files.show_more")}
128
+ </button>
129
+ </div>
130
+ ) : null}
131
+ </>
132
+ )}
133
+ </>
134
+ )}
135
+ </div>
136
+ );
137
+ }
138
+
139
+ function FileRow({ file }: { file: PortalFile }) {
140
+ const t = useThemeTokens();
141
+ const tr = useForgeT();
142
+ const { formatDate } = usePortal();
143
+ const [error, setError] = useState<string | null>(null);
144
+ const Icon = ICONS[file.source];
145
+ const size = formatFileSize(file.size);
146
+ const title = file.source === "invoice" ? tr("forge.portal_files.invoice_title", { number: file.title }) : file.title;
147
+
148
+ return (
149
+ <PortalRow testId="portal-file">
150
+ <span
151
+ aria-hidden
152
+ className="fg:flex fg:h-9 fg:w-9 fg:shrink-0 fg:items-center fg:justify-center"
153
+ style={{ borderRadius: t.cornerRadius, border: `1px solid ${t.border}`, color: t.muted }}
154
+ >
155
+ <Icon size={16} />
156
+ </span>
157
+ <div className="fg:min-w-0 fg:flex-1">
158
+ <p className="fg:truncate" style={{ margin: 0, fontWeight: 600 }} title={file.filename ?? undefined}>
159
+ {title}
160
+ </p>
161
+ <p className="fg:flex fg:flex-wrap fg:gap-x-3" style={{ margin: "2px 0 0", fontSize: 13, color: t.muted }}>
162
+ {file.projectName ? <span>{file.projectName}</span> : null}
163
+ <span>{formatDate(file.createdAt)}</span>
164
+ {size ? <span>{size}</span> : null}
165
+ </p>
166
+ {error ? (
167
+ <p role="alert" style={{ margin: "4px 0 0", fontSize: 13, color: t.text, fontWeight: 600 }}>
168
+ {error}
169
+ </p>
170
+ ) : null}
171
+ </div>
172
+ <FileAction file={file} onError={setError} />
173
+ </PortalRow>
174
+ );
175
+ }
176
+
177
+ function FileAction({ file, onError }: { file: PortalFile; onError: (message: string | null) => void }) {
178
+ const tr = useForgeT();
179
+ const { apiUrl } = useForge();
180
+ const buttons = usePortalButtons();
181
+ const api = (apiUrl ?? "").replace(/\/$/, "");
182
+ const external = (href: string, label: ReactNode, download = false) => (
183
+ <a
184
+ href={href}
185
+ target="_blank"
186
+ rel="noopener noreferrer"
187
+ download={download || undefined}
188
+ data-testid="portal-file-action"
189
+ style={buttons.quiet}
190
+ >
191
+ {label}
192
+ </a>
193
+ );
194
+
195
+ switch (file.open.kind) {
196
+ case "project_document":
197
+ return (
198
+ <ProjectDocumentAction projectId={file.open.projectId} documentId={file.open.documentId} onError={onError} />
199
+ );
200
+ case "task_attachment":
201
+ return external(file.open.url, tr("forge.portal_files.open"));
202
+ case "signed_document":
203
+ return api ? (
204
+ external(
205
+ `${api}/public/documents/${encodeURIComponent(file.open.token)}/download`,
206
+ tr("forge.portal_files.download"),
207
+ true,
208
+ )
209
+ ) : (
210
+ <PortalLink href={internalPath("documents/$token", { token: file.open.token })} style={buttons.quiet}>
211
+ {tr("forge.portal_files.open")}
212
+ </PortalLink>
213
+ );
214
+ case "invoice_pdf":
215
+ return api ? (
216
+ external(
217
+ `${api}/public/invoices/${encodeURIComponent(file.open.invoiceId)}/pdf`,
218
+ tr("forge.portal_files.download"),
219
+ true,
220
+ )
221
+ ) : (
222
+ <PortalLink href={internalPath("invoice/$invoiceId", { invoiceId: file.open.invoiceId })} style={buttons.quiet}>
223
+ {tr("forge.portal_files.open")}
224
+ </PortalLink>
225
+ );
226
+ case "purchase_download":
227
+ // The download page, never a file address: it signs each file on the
228
+ // click, after checking it is this person's.
229
+ return (
230
+ <PortalLink
231
+ href={internalPath("downloads/$token", { token: file.open.token })}
232
+ data-testid="portal-file-action"
233
+ style={buttons.quiet}
234
+ >
235
+ {tr("forge.portal_files.download")}
236
+ </PortalLink>
237
+ );
238
+ }
239
+ }
240
+
241
+ /**
242
+ * A shared project file has no address until the click asks for one: a
243
+ * sixty-second signed read, minted and audited per open, followed once.
244
+ */
245
+ function ProjectDocumentAction({
246
+ projectId,
247
+ documentId,
248
+ onError,
249
+ }: {
250
+ projectId: string;
251
+ documentId: string;
252
+ onError: (message: string | null) => void;
253
+ }) {
254
+ const tr = useForgeT();
255
+ const buttons = usePortalButtons();
256
+ const open = useWorkOpenSharedDocument(projectId);
257
+
258
+ const onClick = async () => {
259
+ onError(null);
260
+ try {
261
+ const { url } = await open.mutateAsync(documentId);
262
+ window.open(url, "_blank", "noopener");
263
+ } catch {
264
+ onError(tr("forge.portal_files.open_error"));
265
+ }
266
+ };
267
+
268
+ return (
269
+ <button
270
+ type="button"
271
+ data-testid="portal-file-action"
272
+ onClick={() => void onClick()}
273
+ disabled={open.isPending}
274
+ style={{ ...buttons.quiet, cursor: open.isPending ? "wait" : "pointer", opacity: open.isPending ? 0.6 : 1 }}
275
+ >
276
+ {tr("forge.portal_files.open")}
277
+ </button>
278
+ );
279
+ }