@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,222 @@
1
+ import { useState } from "react";
2
+ import { CheckCircle2, FolderOpen } from "lucide-react";
3
+ import { useForgeTheme } from "../../theme/ForgeThemeProvider";
4
+ import { usePublicAuth } from "../../../contexts/PublicAuthContext";
5
+ import { ACCESS_TOKEN_KEY } from "../../../contexts/PublicAuthContext";
6
+ import { Loading } from "../Loading";
7
+ import { useAcceptWorkInvite, useWorkInvite } from "../../headless/work/useWorkPortal";
8
+
9
+ export interface WorkInviteAcceptProps {
10
+ /** Invite token from the email link (read from search params by the shell). */
11
+ token?: string;
12
+ /** Project id from the email link. */
13
+ projectId?: string;
14
+ /** Base path for a project in the portal. The project link is
15
+ * `${projectHrefBase}/${projectId}`. Defaults to `/i/work/projects`. */
16
+ projectHrefBase?: string;
17
+ /** Href to the portal login (shown on an invalid/expired link). */
18
+ loginHref?: string;
19
+ }
20
+
21
+ const msg = (e: unknown) => (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
22
+
23
+ /**
24
+ * Branded, AUTH-AWARE client-invite accept page. Shows who invited the client
25
+ * to which project, then:
26
+ * • already signed in as the invited person → "View project" (no password),
27
+ * • signed in as someone else → accept as the invited person (set password),
28
+ * • not signed in → set a password to accept (mints a portal session).
29
+ * On accept it persists the returned session token and navigates to the
30
+ * project. Theme tokens only — it inherits the artist's site styling.
31
+ */
32
+ export function WorkInviteAccept({
33
+ token,
34
+ projectId,
35
+ projectHrefBase = "/i/work/projects",
36
+ loginHref = "/i/login",
37
+ }: WorkInviteAcceptProps) {
38
+ const theme = useForgeTheme();
39
+ const { isAuthenticated, user } = usePublicAuth();
40
+ const { data: invite, isLoading, isError } = useWorkInvite(token, projectId);
41
+ const acceptInvite = useAcceptWorkInvite();
42
+
43
+ const [password, setPassword] = useState("");
44
+ const [confirm, setConfirm] = useState("");
45
+ const [error, setError] = useState("");
46
+ const [submitting, setSubmitting] = useState(false);
47
+
48
+ const projectHref = `${projectHrefBase}/${projectId}`;
49
+
50
+ const card: React.CSSProperties = {
51
+ maxWidth: 460,
52
+ margin: "40px auto 0",
53
+ padding: 28,
54
+ border: `1px solid ${theme.colors.primary}33`,
55
+ borderRadius: theme.cornerRadius,
56
+ color: theme.colors.text,
57
+ background: theme.colors.background,
58
+ };
59
+ const input: React.CSSProperties = {
60
+ width: "100%",
61
+ height: 42,
62
+ padding: "8px 12px",
63
+ borderRadius: theme.cornerRadius,
64
+ border: `1px solid ${theme.colors.primary}40`,
65
+ background: "transparent",
66
+ color: theme.colors.text,
67
+ outline: "none",
68
+ };
69
+ const button: React.CSSProperties = {
70
+ width: "100%",
71
+ padding: "11px 14px",
72
+ borderRadius: theme.cornerRadius,
73
+ background: theme.colors.primary,
74
+ color: theme.colors.background,
75
+ border: "none",
76
+ fontWeight: 700,
77
+ cursor: "pointer",
78
+ textAlign: "center",
79
+ textDecoration: "none",
80
+ display: "inline-flex",
81
+ alignItems: "center",
82
+ justifyContent: "center",
83
+ gap: 8,
84
+ };
85
+ const link: React.CSSProperties = { color: theme.colors.primary, textDecoration: "underline" };
86
+ const eyebrow: React.CSSProperties = { fontSize: 13, color: theme.colors.primary, fontWeight: 700, letterSpacing: 0.3 };
87
+ const heading: React.CSSProperties = { fontSize: 22, fontWeight: 800, margin: "6px 0 14px" };
88
+ const sub: React.CSSProperties = { fontSize: 14, color: `${theme.colors.text}b3`, marginBottom: 22 };
89
+
90
+ if (!token || !projectId) {
91
+ return (
92
+ <div style={card} data-testid="work-invite-accept">
93
+ <h1 style={heading} data-testid="work-invite-invalid">
94
+ Invalid invite link
95
+ </h1>
96
+ <p style={sub}>This invite link is missing information. Please use the link from your email.</p>
97
+ <a href={loginHref} style={link}>
98
+ Go to sign in
99
+ </a>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ if (isLoading) {
105
+ return (
106
+ <div style={card} data-testid="work-invite-accept">
107
+ <Loading />
108
+ </div>
109
+ );
110
+ }
111
+
112
+ if (isError || !invite) {
113
+ return (
114
+ <div style={card} data-testid="work-invite-accept">
115
+ <h1 style={heading} data-testid="work-invite-invalid">
116
+ Invite not available
117
+ </h1>
118
+ <p style={sub}>This invite is invalid or has expired. Ask your provider to send a new one.</p>
119
+ <a href={loginHref} style={link}>
120
+ Go to sign in
121
+ </a>
122
+ </div>
123
+ );
124
+ }
125
+
126
+ const invitedEmail = invite.email?.toLowerCase() ?? "";
127
+ const currentEmail = (user?.email ?? "").toLowerCase();
128
+ const signedInAsInvitee = isAuthenticated && currentEmail && currentEmail === invitedEmail;
129
+
130
+ const header = (
131
+ <>
132
+ <div style={eyebrow}>{invite.profileName}</div>
133
+ <h1 style={heading} data-testid="work-invite-heading">
134
+ You're invited to <span style={{ color: theme.colors.primary }}>{invite.projectName}</span>
135
+ </h1>
136
+ </>
137
+ );
138
+
139
+ // Already signed in AS the invited person → nothing to do but open the project.
140
+ if (signedInAsInvitee) {
141
+ return (
142
+ <div style={card} data-testid="work-invite-accept">
143
+ {header}
144
+ <p style={sub}>
145
+ <CheckCircle2 size={16} style={{ verticalAlign: "-2px", color: theme.colors.primary }} /> You're signed in as{" "}
146
+ <strong>{invite.email}</strong>. You already have access.
147
+ </p>
148
+ <a href={projectHref} style={button} data-testid="work-invite-view-project">
149
+ <FolderOpen size={16} /> View project
150
+ </a>
151
+ </div>
152
+ );
153
+ }
154
+
155
+ const onAccept = async (e: React.FormEvent) => {
156
+ e.preventDefault();
157
+ setError("");
158
+ if (password.length < 8) {
159
+ setError("Password must be at least 8 characters.");
160
+ return;
161
+ }
162
+ if (password !== confirm) {
163
+ setError("Passwords don't match.");
164
+ return;
165
+ }
166
+ setSubmitting(true);
167
+ try {
168
+ const result = await acceptInvite.mutateAsync({ token, projectId, password });
169
+ // Persist the fresh portal session and hard-navigate so the auth context
170
+ // re-initializes as the invited client, then lands on the project.
171
+ localStorage.setItem(ACCESS_TOKEN_KEY, result.token);
172
+ window.location.assign(projectHref);
173
+ } catch (err) {
174
+ setError(msg(err) || "Something went wrong. Please try again.");
175
+ setSubmitting(false);
176
+ }
177
+ };
178
+
179
+ return (
180
+ <div style={card} data-testid="work-invite-accept">
181
+ {header}
182
+ <p style={sub}>
183
+ {isAuthenticated && currentEmail && currentEmail !== invitedEmail ? (
184
+ <>
185
+ You're signed in as <strong>{user?.email}</strong>, but this invite is for{" "}
186
+ <strong>{invite.email}</strong>. Set a password to continue as {invite.email}.
187
+ </>
188
+ ) : (
189
+ <>
190
+ Set a password for <strong>{invite.email}</strong> to access your client portal.
191
+ </>
192
+ )}
193
+ </p>
194
+ {error && (
195
+ <p style={{ color: "#ef4444", marginBottom: 14, fontSize: 14 }} data-testid="work-invite-error">
196
+ {error}
197
+ </p>
198
+ )}
199
+ <form onSubmit={onAccept} style={{ display: "flex", flexDirection: "column", gap: 14 }}>
200
+ <input
201
+ type="password"
202
+ placeholder="Create a password"
203
+ value={password}
204
+ onChange={(e) => setPassword(e.target.value)}
205
+ style={input}
206
+ data-testid="work-invite-password"
207
+ />
208
+ <input
209
+ type="password"
210
+ placeholder="Confirm password"
211
+ value={confirm}
212
+ onChange={(e) => setConfirm(e.target.value)}
213
+ style={input}
214
+ data-testid="work-invite-confirm"
215
+ />
216
+ <button type="submit" disabled={submitting} style={button} data-testid="work-invite-submit">
217
+ {submitting ? "Accepting…" : "Accept invite"}
218
+ </button>
219
+ </form>
220
+ </div>
221
+ );
222
+ }
@@ -0,0 +1,163 @@
1
+ import { ArrowRight, FolderKanban } from "lucide-react";
2
+ import { useThemeTokens, type ResolvedThemeTokens } from "../../theme/ForgeThemeProvider";
3
+ import { Loading } from "../Loading";
4
+ import {
5
+ formatWorkDate,
6
+ useWorkPortalProjects,
7
+ type WorkProjectSummary,
8
+ } from "../../headless/work/useWorkPortal";
9
+
10
+ export interface WorkPortalProjectsProps {
11
+ /** Build the href for a project's report page. Rendered as an `<a>` unless
12
+ * `onOpenProject` is supplied. Defaults to `/i/work/projects/{id}`. */
13
+ projectHref?: (projectId: string) => string;
14
+ /** Router-driven open handler. When provided, cards render as buttons calling
15
+ * this instead of anchors (lets the host app keep its own navigation/prefix). */
16
+ onOpenProject?: (projectId: string) => void;
17
+ /** Heading above the grid. Pass `null` to hide it. */
18
+ heading?: string | null;
19
+ }
20
+
21
+ /**
22
+ * The authenticated client's read-only list of their (non-archived) client
23
+ * projects, as cards with a task/done progress bar. Built on
24
+ * `useWorkPortalProjects`; theme tokens only. Tapping a card opens the project
25
+ * report (`WorkProjectReport`).
26
+ */
27
+ export function WorkPortalProjects({
28
+ projectHref = (id) => `/i/work/projects/${id}`,
29
+ onOpenProject,
30
+ heading = "Your projects",
31
+ }: WorkPortalProjectsProps = {}) {
32
+ const t = useThemeTokens();
33
+ const { data: projects, isLoading } = useWorkPortalProjects();
34
+
35
+ if (isLoading) {
36
+ return <Loading fullPage />;
37
+ }
38
+
39
+ if (!projects || projects.length === 0) {
40
+ return (
41
+ <div
42
+ data-testid="work-portal-empty"
43
+ style={{
44
+ border: `1px solid ${t.border}`,
45
+ borderRadius: t.cornerRadius,
46
+ background: t.surface,
47
+ padding: 32,
48
+ textAlign: "center",
49
+ color: t.text,
50
+ fontFamily: t.fontFamily,
51
+ }}
52
+ >
53
+ <FolderKanban size={28} style={{ margin: "0 auto 12px", color: t.primary }} />
54
+ <p style={{ margin: 0, color: t.muted }}>You don&apos;t have any projects yet.</p>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ return (
60
+ <div
61
+ data-testid="work-portal-projects"
62
+ style={{ display: "flex", flexDirection: "column", gap: 24, color: t.text, fontFamily: t.fontFamily }}
63
+ >
64
+ {heading && <h1 style={{ fontSize: 22, fontWeight: 800, margin: 0, color: t.text }}>{heading}</h1>}
65
+ <div
66
+ style={{
67
+ display: "grid",
68
+ gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
69
+ gap: 12,
70
+ }}
71
+ >
72
+ {projects.map((p) => (
73
+ <ProjectCard
74
+ key={p.id}
75
+ project={p}
76
+ t={t}
77
+ href={onOpenProject ? undefined : projectHref(p.id)}
78
+ onOpen={onOpenProject ? () => onOpenProject(p.id) : undefined}
79
+ />
80
+ ))}
81
+ </div>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ function ProjectCard({
87
+ project,
88
+ t,
89
+ href,
90
+ onOpen,
91
+ }: {
92
+ project: WorkProjectSummary;
93
+ t: ResolvedThemeTokens;
94
+ href?: string;
95
+ onOpen?: () => void;
96
+ }) {
97
+ const pct = project.taskCount > 0 ? Math.round((project.doneCount / project.taskCount) * 100) : 0;
98
+
99
+ const inner = (
100
+ <>
101
+ <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8 }}>
102
+ <span style={{ fontWeight: 600, color: t.text }}>{project.name}</span>
103
+ <ArrowRight size={16} style={{ color: t.muted, flexShrink: 0 }} />
104
+ </div>
105
+
106
+ <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
107
+ <span
108
+ style={{
109
+ fontSize: 12,
110
+ fontWeight: 600,
111
+ padding: "2px 8px",
112
+ borderRadius: 999,
113
+ textTransform: "capitalize",
114
+ background: t.border,
115
+ color: t.primary,
116
+ }}
117
+ >
118
+ {project.status.replace(/_/g, " ")}
119
+ </span>
120
+ <span style={{ fontSize: 12, color: t.muted }}>Due {formatWorkDate(project.dueDate)}</span>
121
+ </div>
122
+
123
+ <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
124
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", fontSize: 12, color: t.muted }}>
125
+ <span>
126
+ {project.doneCount} / {project.taskCount} tasks
127
+ </span>
128
+ <span>{pct}%</span>
129
+ </div>
130
+ <div style={{ width: "100%", height: 6, borderRadius: 999, overflow: "hidden", background: t.border }}>
131
+ <div style={{ height: "100%", borderRadius: 999, width: `${pct}%`, background: t.primary }} />
132
+ </div>
133
+ </div>
134
+ </>
135
+ );
136
+
137
+ const style: React.CSSProperties = {
138
+ display: "flex",
139
+ flexDirection: "column",
140
+ gap: 12,
141
+ textAlign: "left",
142
+ padding: 20,
143
+ cursor: "pointer",
144
+ border: `1px solid ${t.border}`,
145
+ borderRadius: t.cornerRadius,
146
+ background: t.surface,
147
+ color: t.text,
148
+ textDecoration: "none",
149
+ };
150
+
151
+ if (onOpen) {
152
+ return (
153
+ <button type="button" data-testid="work-project-card" onClick={onOpen} style={style}>
154
+ {inner}
155
+ </button>
156
+ );
157
+ }
158
+ return (
159
+ <a data-testid="work-project-card" href={href} style={style}>
160
+ {inner}
161
+ </a>
162
+ );
163
+ }
@@ -0,0 +1,113 @@
1
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
2
+ import {
3
+ formatWorkDate,
4
+ formatWorkMoney,
5
+ useWorkProjectInvoices,
6
+ type WorkProjectInvoice,
7
+ } from "../../headless/work/useWorkPortal";
8
+
9
+ export interface WorkProjectInvoicesProps {
10
+ /** The client project id. */
11
+ projectId: string;
12
+ /** Build the href for an invoice's pay/detail page. Defaults to
13
+ * `/i/invoice/{id}`. */
14
+ invoiceHref?: (invoiceId: string) => string;
15
+ }
16
+
17
+ const STATUS_LABEL: Record<string, string> = {
18
+ sent: "Awaiting payment",
19
+ overdue: "Overdue",
20
+ initiated_payment: "Payment started",
21
+ payment_failed: "Payment failed",
22
+ paid: "Paid",
23
+ cancelled: "Cancelled",
24
+ };
25
+
26
+ /**
27
+ * The client's invoices for a project, with a Pay button for anything still
28
+ * owing (links to the hosted invoice page). Read-only otherwise. Built on
29
+ * `useWorkProjectInvoices`; theme tokens only. Renders nothing when there are
30
+ * no invoices yet.
31
+ */
32
+ export function WorkProjectInvoices({ projectId, invoiceHref }: WorkProjectInvoicesProps) {
33
+ const t = useThemeTokens();
34
+ const { data: invoices, isLoading } = useWorkProjectInvoices(projectId);
35
+ const href = invoiceHref ?? ((id: string) => `/i/invoice/${id}`);
36
+
37
+ if (isLoading || !invoices || invoices.length === 0) return null;
38
+
39
+ const statusColor = (inv: WorkProjectInvoice) =>
40
+ inv.status === "paid" ? t.primary : inv.status === "overdue" || inv.status === "payment_failed" ? "#ef4444" : t.muted;
41
+
42
+ return (
43
+ <div
44
+ data-testid="work-project-invoices"
45
+ style={{
46
+ border: `1px solid ${t.border}`,
47
+ borderRadius: t.cornerRadius,
48
+ background: t.surface,
49
+ padding: 20,
50
+ color: t.text,
51
+ fontFamily: t.fontFamily,
52
+ }}
53
+ >
54
+ <h2 style={{ fontSize: 16, fontWeight: 800, margin: "0 0 14px" }}>Invoices</h2>
55
+ <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
56
+ {invoices.map((inv) => (
57
+ <div
58
+ key={inv.id}
59
+ data-testid={`work-invoice-${inv.id}`}
60
+ style={{
61
+ display: "flex",
62
+ alignItems: "center",
63
+ justifyContent: "space-between",
64
+ gap: 12,
65
+ flexWrap: "wrap",
66
+ padding: "12px 14px",
67
+ border: `1px solid ${t.border}`,
68
+ borderRadius: t.cornerRadius,
69
+ }}
70
+ >
71
+ <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
72
+ <span style={{ fontWeight: 700 }}>{inv.invoiceNumber}</span>
73
+ <span style={{ fontSize: 12, color: statusColor(inv) }}>
74
+ {STATUS_LABEL[inv.status] ?? inv.status}
75
+ {inv.status === "paid" && inv.paidAt ? ` · ${formatWorkDate(inv.paidAt)}` : ""}
76
+ {inv.payable && inv.dueDate ? ` · due ${formatWorkDate(inv.dueDate)}` : ""}
77
+ </span>
78
+ </div>
79
+ <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
80
+ <span style={{ fontWeight: 700 }}>{formatWorkMoney(inv.total, inv.currency)}</span>
81
+ {inv.payable ? (
82
+ <a
83
+ href={href(inv.id)}
84
+ data-testid={`work-invoice-pay-${inv.id}`}
85
+ style={{
86
+ display: "inline-block",
87
+ padding: "8px 16px",
88
+ borderRadius: t.cornerRadius,
89
+ background: t.primary,
90
+ color: t.background,
91
+ fontWeight: 700,
92
+ fontSize: 13,
93
+ textDecoration: "none",
94
+ }}
95
+ >
96
+ Pay
97
+ </a>
98
+ ) : (
99
+ <a
100
+ href={href(inv.id)}
101
+ data-testid={`work-invoice-view-${inv.id}`}
102
+ style={{ color: t.primary, fontSize: 13, textDecoration: "underline" }}
103
+ >
104
+ View
105
+ </a>
106
+ )}
107
+ </div>
108
+ </div>
109
+ ))}
110
+ </div>
111
+ </div>
112
+ );
113
+ }
@@ -0,0 +1,66 @@
1
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
2
+ import { Loading } from "../Loading";
3
+ import { useWorkProjectReport } from "../../headless/work/useWorkPortal";
4
+ import { WorkReportBody } from "./WorkReportBody";
5
+ import { WorkProjectInvoices } from "./WorkProjectInvoices";
6
+
7
+ export interface WorkProjectReportProps {
8
+ /** The client project id (from the route). */
9
+ projectId: string;
10
+ /** Build the href for a task's detail/comments page. Defaults to
11
+ * `/i/work/projects/{projectId}/tasks/{taskId}`. Pass `null` to keep tasks
12
+ * read-only (no per-task navigation). */
13
+ taskHref?: ((taskId: string) => string) | null;
14
+ /** Router-driven open handler for a task. When provided, task rows render as
15
+ * buttons calling this instead of anchors. */
16
+ onOpenTask?: (taskId: string) => void;
17
+ }
18
+
19
+ /**
20
+ * The authenticated client's read-only project report — milestones, task list
21
+ * and the billable-only time report. Each task links to its detail/comments
22
+ * page. Built on `useWorkProjectReport`; theme tokens only.
23
+ */
24
+ export function WorkProjectReport({ projectId, taskHref, onOpenTask }: WorkProjectReportProps) {
25
+ const t = useThemeTokens();
26
+ const { data: report, isLoading, isError } = useWorkProjectReport(projectId);
27
+
28
+ if (isLoading) {
29
+ return <Loading fullPage />;
30
+ }
31
+
32
+ if (isError || !report) {
33
+ return (
34
+ <div
35
+ data-testid="work-project-report-error"
36
+ style={{
37
+ border: `1px solid ${t.border}`,
38
+ borderRadius: t.cornerRadius,
39
+ background: t.surface,
40
+ padding: 32,
41
+ textAlign: "center",
42
+ color: t.text,
43
+ fontFamily: t.fontFamily,
44
+ }}
45
+ >
46
+ <p style={{ margin: 0, color: t.muted }}>We couldn&apos;t find this project.</p>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ const resolvedTaskHref =
52
+ taskHref === null
53
+ ? undefined
54
+ : onOpenTask
55
+ ? undefined
56
+ : (taskHref ?? ((taskId: string) => `/i/work/projects/${projectId}/tasks/${taskId}`));
57
+
58
+ // The invoices section is authed-only (not part of the login-free token
59
+ // share, which renders WorkReportBody directly).
60
+ return (
61
+ <div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
62
+ <WorkReportBody report={report} taskHref={resolvedTaskHref} onOpenTask={onOpenTask} />
63
+ <WorkProjectInvoices projectId={projectId} />
64
+ </div>
65
+ );
66
+ }