@tribe-nest/forge 3.60.0 → 3.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/package.json +5 -2
  2. package/src/contexts/CartContext.tsx +34 -1
  3. package/src/contexts/PublicAuthContext.tsx +15 -3
  4. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  5. package/src/data/queries/useAccount.ts +33 -2
  6. package/src/data/queries/useAnalytics.ts +6 -6
  7. package/src/data/queries/useCallChat.ts +2 -2
  8. package/src/data/queries/useChat.ts +10 -1
  9. package/src/data/queries/useCheckouts.ts +2 -1
  10. package/src/data/queries/useCoachingAvailability.ts +10 -1
  11. package/src/data/queries/useCommunities.ts +2 -0
  12. package/src/data/queries/useCommunity.ts +362 -18
  13. package/src/data/queries/useCommunityAgent.ts +247 -0
  14. package/src/data/queries/useCommunityCalendar.ts +150 -0
  15. package/src/data/queries/useCommunityClassroom.ts +75 -0
  16. package/src/data/queries/useCommunityMembers.ts +108 -0
  17. package/src/data/queries/useCommunityModeration.ts +327 -0
  18. package/src/data/queries/useCommunityWiki.ts +137 -0
  19. package/src/data/queries/useCourses.ts +2 -0
  20. package/src/data/queries/useEmailSignIn.ts +79 -0
  21. package/src/data/queries/useEvents.ts +6 -1
  22. package/src/data/queries/useForms.ts +5 -1
  23. package/src/data/queries/useLeadMagnet.ts +6 -2
  24. package/src/data/queries/useMyBookings.ts +24 -1
  25. package/src/data/queries/useMyTickets.ts +24 -1
  26. package/src/data/queries/useNotifications.ts +6 -2
  27. package/src/data/queries/useOrders.ts +2 -1
  28. package/src/data/queries/usePWA.ts +111 -35
  29. package/src/data/queries/useSiteApp.ts +130 -0
  30. package/src/data/queries/useSubscriptions.ts +6 -1
  31. package/src/i18n/de.json +746 -1
  32. package/src/i18n/en.json +746 -1
  33. package/src/index.ts +20 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/seo/_tests/seo.spec.ts +326 -0
  37. package/src/seo/applySeoToHead.ts +98 -0
  38. package/src/seo/index.ts +9 -0
  39. package/src/seo/resolve.ts +211 -0
  40. package/src/seo/seoHead.ts +61 -0
  41. package/src/seo/siteSeoHead.ts +97 -0
  42. package/src/seo/types.ts +99 -0
  43. package/src/server/_tests/siteBootstrap.spec.ts +65 -0
  44. package/src/server/index.ts +33 -4
  45. package/src/types/models.ts +11 -0
  46. package/src/ui/analytics/ForgeAnalytics.tsx +134 -34
  47. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  48. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  49. package/src/ui/analytics/_tests/ForgeAnalytics.webVitals.spec.tsx +129 -0
  50. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  51. package/src/ui/analytics/useMetaEvents.ts +78 -0
  52. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  53. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  54. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  55. package/src/ui/headless/_tests/communityHarness.tsx +10 -3
  56. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  57. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  58. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  59. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  60. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  61. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  62. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  63. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  64. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  65. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  66. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  67. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  68. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  69. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  70. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  71. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  72. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  73. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  74. package/src/ui/headless/communities/index.ts +41 -0
  75. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  76. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  77. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  78. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  79. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  80. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  81. package/src/ui/headless/community/communityPoll.ts +297 -0
  82. package/src/ui/headless/community/countries.ts +123 -0
  83. package/src/ui/headless/community/index.ts +81 -1
  84. package/src/ui/headless/community/linkPreview.ts +71 -0
  85. package/src/ui/headless/community/richLexical.ts +27 -2
  86. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  87. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  88. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  89. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  90. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  91. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  92. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  93. package/src/ui/headless/downloads/index.ts +8 -0
  94. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  95. package/src/ui/headless/index.ts +13 -0
  96. package/src/ui/headless/memberHome/index.ts +10 -0
  97. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  98. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  99. package/src/ui/headless/portal/index.ts +57 -0
  100. package/src/ui/headless/portal/usePortal.ts +274 -0
  101. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  102. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  103. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  104. package/src/ui/headless/siteApp/appBar.ts +102 -0
  105. package/src/ui/headless/siteApp/appHome.ts +344 -0
  106. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  107. package/src/ui/headless/siteApp/index.ts +58 -0
  108. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  109. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  110. package/src/ui/headless/work/index.ts +2 -0
  111. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  112. package/src/ui/index.ts +11 -0
  113. package/src/ui/styled/AccountDashboard.tsx +712 -352
  114. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  115. package/src/ui/styled/ChatRoom.tsx +126 -16
  116. package/src/ui/styled/Checkout.tsx +8 -0
  117. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  118. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  119. package/src/ui/styled/CoachingDetail.tsx +2 -0
  120. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  121. package/src/ui/styled/CourseDetail.tsx +2 -0
  122. package/src/ui/styled/DownloadPage.tsx +263 -0
  123. package/src/ui/styled/EventConfirmation.tsx +10 -0
  124. package/src/ui/styled/EventDetail.tsx +10 -0
  125. package/src/ui/styled/FilmLibrary.tsx +22 -9
  126. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  127. package/src/ui/styled/ProductDetail.tsx +12 -0
  128. package/src/ui/styled/PwaRegistration.tsx +5 -1
  129. package/src/ui/styled/SignupForm.tsx +18 -3
  130. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  131. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  132. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  133. package/src/ui/styled/app/AppBar.tsx +298 -0
  134. package/src/ui/styled/app/AppHome.tsx +1139 -0
  135. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  136. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  137. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  138. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  139. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  140. package/src/ui/styled/app/index.ts +7 -0
  141. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  142. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  143. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  144. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  145. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  146. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  147. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  148. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  149. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  150. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  151. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  152. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  153. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  154. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  155. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  156. package/src/ui/styled/communities/index.ts +9 -0
  157. package/src/ui/styled/communities/shared.tsx +39 -11
  158. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  159. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  160. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  161. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  162. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  163. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  164. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  165. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  166. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  167. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  168. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  169. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  170. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  171. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  172. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  173. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  174. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  175. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  176. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  177. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  178. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  179. package/src/ui/styled/community/index.ts +37 -2
  180. package/src/ui/styled/forge-utilities.css +234 -0
  181. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  182. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  183. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  184. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  185. package/src/ui/styled/members/index.ts +8 -0
  186. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  187. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  188. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  189. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  190. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  191. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  192. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  193. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  194. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  195. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  196. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  197. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  198. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  199. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  200. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  201. package/src/ui/styled/portal/index.ts +13 -0
  202. package/src/ui/styled/portal/portalNav.ts +260 -0
  203. package/src/ui/styled/portal/shared.tsx +206 -0
  204. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  205. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  206. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  207. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  208. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  209. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  210. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  211. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  212. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  213. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  214. package/src/ui/styled/work/index.ts +1 -1
  215. package/src/utils/_tests/landing.spec.ts +39 -0
  216. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  217. package/src/utils/_tests/structuredDataSeo.spec.ts +247 -0
  218. package/src/utils/landing.ts +36 -10
  219. package/src/utils/metaEvents.ts +302 -0
  220. package/src/utils/structuredData.ts +339 -1
@@ -1,11 +1,17 @@
1
- import { useState } from "react";
2
- import { CheckCircle2, FolderOpen } from "lucide-react";
3
- import { useForgeTheme } from "../../theme/ForgeThemeProvider";
1
+ import { Fragment, useEffect, useState, type CSSProperties, type ReactNode } from "react";
2
+ import { FolderOpen } from "lucide-react";
3
+ import { useForgeT } from "../../../i18n";
4
+ import { internalPath } from "../../../runtime/pages";
5
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
4
6
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
5
- import { ACCESS_TOKEN_KEY } from "../../../contexts/PublicAuthContext";
6
- import { PUBLIC_REFRESH_TOKEN_KEY } from "../../../client/tokenStorage";
7
7
  import { Loading } from "../Loading";
8
- import { useAcceptWorkInvite, useWorkInvite } from "../../headless/work/useWorkPortal";
8
+ import {
9
+ rememberSignupEmail,
10
+ useAcceptWorkInvite,
11
+ useWorkInvite,
12
+ } from "../../headless/work/useWorkPortal";
13
+ import { usePortalButtons } from "../portal/shared";
14
+ import { requestAppInstall } from "../../headless/siteApp/appPrompts";
9
15
 
10
16
  export interface WorkInviteAcceptProps {
11
17
  /** Invite token from the email link (read from search params by the shell). */
@@ -13,96 +19,163 @@ export interface WorkInviteAcceptProps {
13
19
  /** Project id from the email link. */
14
20
  projectId?: string;
15
21
  /** Base path for a project in the portal. The project link is
16
- * `${projectHrefBase}/${projectId}`. Defaults to `/i/work/projects`. */
22
+ * `${projectHrefBase}/${projectId}`. Defaults to `internalPath("account/projects")`. */
17
23
  projectHrefBase?: string;
18
- /** Href to the portal login (shown on an invalid/expired link). */
24
+ /** The portal login. A `redirect` back to this invite is appended. Defaults to
25
+ * `internalPath("login")`. */
19
26
  loginHref?: string;
27
+ /** The standard signup page. A `redirect` back to this invite is appended.
28
+ * Defaults to `internalPath("signup")`. */
29
+ signupHref?: string;
20
30
  }
21
31
 
22
32
  const msg = (e: unknown) => (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
33
+ const status = (e: unknown) => (e as { response?: { status?: number } })?.response?.status;
34
+
35
+ /** Where the token waits while the person signs up or signs in (this tab only). */
36
+ const stashKey = (projectId: string) => `forge:work-invite:${projectId}`;
37
+
38
+ const readStash = (projectId?: string): string | undefined => {
39
+ if (!projectId || typeof window === "undefined") return undefined;
40
+ try {
41
+ return window.sessionStorage.getItem(stashKey(projectId)) ?? undefined;
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ };
46
+
47
+ const writeStash = (projectId: string, token: string): boolean => {
48
+ try {
49
+ window.sessionStorage.setItem(stashKey(projectId), token);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ };
55
+
56
+ const clearStash = (projectId: string) => {
57
+ try {
58
+ window.sessionStorage.removeItem(stashKey(projectId));
59
+ } catch {
60
+ // Storage unavailable: nothing was stashed.
61
+ }
62
+ };
63
+
64
+ /**
65
+ * A translated sentence with some of its placeholders rendered in bold.
66
+ *
67
+ * The bundle holds the WHOLE sentence (`"You're signed in as {email}."`), so a
68
+ * translator can move the address wherever the language needs it. The
69
+ * placeholders are then swapped for nodes here instead of being filled as text.
70
+ */
71
+ function Rich({ text, values }: { text: string; values: Record<string, string> }) {
72
+ const parts = text.split(/(\{[a-z_]+\})/g);
73
+ return (
74
+ <>
75
+ {parts.map((part, i) => {
76
+ const name = /^\{([a-z_]+)\}$/.exec(part)?.[1];
77
+ if (name && values[name] !== undefined) return <strong key={i}>{values[name]}</strong>;
78
+ return <Fragment key={i}>{part}</Fragment>;
79
+ })}
80
+ </>
81
+ );
82
+ }
23
83
 
24
84
  /**
25
- * Branded, AUTH-AWARE client-invite accept page. Shows who invited the client
26
- * to which project, then:
27
- * • already signed in as the invited person → "View project" (no password),
28
- * • signed in as someone else → accept as the invited person (set password),
29
- * • not signed in → set a password to accept (mints a portal session).
30
- * On accept it persists the returned session token and navigates to the
31
- * project. Theme tokens only — it inherits the artist's site styling.
85
+ * Branded, AUTH-AWARE client-invite accept page.
86
+ *
87
+ * The invite never creates an account or sets a password: the link lands on the
88
+ * creator's own site, where page scripts can read it. So:
89
+ * - signed in AS the invited address: "Open project" accepts and opens it,
90
+ * - signed in as someone else: sign out and sign in as the invited address,
91
+ * - not signed in, address has an account: sign in, then come back here,
92
+ * - not signed in, no account yet: the normal signup (which verifies the
93
+ * address with an emailed code), prefilled with the invited address, then
94
+ * back here.
95
+ *
96
+ * The token is read once, kept in component state and this tab's session
97
+ * storage, and removed from the address bar so analytics fired later on the
98
+ * page never see it. Theme tokens only, so it inherits the artist's styling.
32
99
  */
33
100
  export function WorkInviteAccept({
34
- token,
101
+ token: tokenProp,
35
102
  projectId,
36
- projectHrefBase = "/i/work/projects",
37
- loginHref = "/i/login",
103
+ projectHrefBase = internalPath("account/projects"),
104
+ loginHref = internalPath("login"),
105
+ signupHref = internalPath("signup"),
38
106
  }: WorkInviteAcceptProps) {
39
- const theme = useForgeTheme();
40
- const { isAuthenticated, user } = usePublicAuth();
107
+ const t = useThemeTokens();
108
+ const tr = useForgeT();
109
+ const buttons = usePortalButtons();
110
+ const { isAuthenticated, user, logout } = usePublicAuth();
111
+
112
+ // Read once: the URL is about to lose it, and a re-render must not.
113
+ const [token] = useState<string | undefined>(() => tokenProp ?? readStash(projectId));
114
+ const [stashed, setStashed] = useState(false);
41
115
  const { data: invite, isLoading, isError } = useWorkInvite(token, projectId);
42
116
  const acceptInvite = useAcceptWorkInvite();
43
-
44
- const [password, setPassword] = useState("");
45
- const [confirm, setConfirm] = useState("");
46
117
  const [error, setError] = useState("");
47
118
  const [submitting, setSubmitting] = useState(false);
48
119
 
120
+ useEffect(() => {
121
+ if (!token || !projectId || typeof window === "undefined") return;
122
+ setStashed(writeStash(projectId, token));
123
+ try {
124
+ const url = new URL(window.location.href);
125
+ if (url.searchParams.has("token")) {
126
+ url.searchParams.delete("token");
127
+ window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
128
+ }
129
+ } catch {
130
+ // A URL the browser cannot parse: leave it alone.
131
+ }
132
+ }, [token, projectId]);
133
+
49
134
  const projectHref = `${projectHrefBase}/${projectId}`;
135
+ // Back to this page after signup or sign-in. The token rides in session
136
+ // storage when it can, so it never reaches the signup or login page's URL.
137
+ const backHere = `${internalPath("account/invite")}?project=${encodeURIComponent(projectId ?? "")}${
138
+ stashed || !token ? "" : `&token=${encodeURIComponent(token)}`
139
+ }`;
140
+ const withRedirect = (href: string) =>
141
+ `${href}${href.includes("?") ? "&" : "?"}redirect=${encodeURIComponent(backHere)}`;
50
142
 
51
- const card: React.CSSProperties = {
143
+ const card: CSSProperties = {
52
144
  maxWidth: 460,
53
145
  margin: "40px auto 0",
54
146
  padding: 28,
55
- border: `1px solid ${theme.colors.primary}33`,
56
- borderRadius: theme.cornerRadius,
57
- color: theme.colors.text,
58
- background: theme.colors.background,
59
- };
60
- const input: React.CSSProperties = {
61
- width: "100%",
62
- height: 42,
63
- padding: "8px 12px",
64
- borderRadius: theme.cornerRadius,
65
- border: `1px solid ${theme.colors.primary}40`,
66
- background: "transparent",
67
- color: theme.colors.text,
68
- outline: "none",
147
+ border: `1px solid ${t.border}`,
148
+ borderRadius: t.cornerRadius,
149
+ color: t.text,
150
+ background: t.background,
151
+ fontFamily: t.fontFamily,
69
152
  };
70
- const button: React.CSSProperties = {
71
- width: "100%",
72
- padding: "11px 14px",
73
- borderRadius: theme.cornerRadius,
74
- background: theme.colors.primary,
75
- color: theme.colors.background,
76
- border: "none",
77
- fontWeight: 700,
78
- cursor: "pointer",
79
- textAlign: "center",
80
- textDecoration: "none",
81
- display: "inline-flex",
82
- alignItems: "center",
83
- justifyContent: "center",
84
- gap: 8,
153
+ const fullWidth: CSSProperties = { ...buttons.primary, width: "100%", minHeight: 44 };
154
+ const eyebrow: CSSProperties = { fontSize: 14, color: t.muted, fontWeight: 600 };
155
+ const heading: CSSProperties = {
156
+ margin: "6px 0 12px",
157
+ fontFamily: t.headingFontFamily,
158
+ fontSize: 24,
159
+ lineHeight: 1.2,
160
+ fontWeight: 600,
161
+ color: t.text,
85
162
  };
86
- const link: React.CSSProperties = { color: theme.colors.primary, textDecoration: "underline" };
87
- const eyebrow: React.CSSProperties = {
88
- fontSize: 13,
89
- color: theme.colors.primary,
90
- fontWeight: 700,
91
- letterSpacing: 0.3,
92
- };
93
- const heading: React.CSSProperties = { fontSize: 22, fontWeight: 800, margin: "6px 0 14px" };
94
- const sub: React.CSSProperties = { fontSize: 14, color: `${theme.colors.text}b3`, marginBottom: 22 };
163
+ const sub: CSSProperties = { margin: "0 0 22px", fontSize: 15, lineHeight: 1.5, color: t.muted };
164
+
165
+ const signInLink = (
166
+ <a href={loginHref} style={buttons.quiet}>
167
+ {tr("forge.work_invite_accept.go_to_sign_in")}
168
+ </a>
169
+ );
95
170
 
96
171
  if (!token || !projectId) {
97
172
  return (
98
173
  <div style={card} data-testid="work-invite-accept">
99
174
  <h1 style={heading} data-testid="work-invite-invalid">
100
- Invalid invite link
175
+ {tr("forge.work_invite_accept.missing_title")}
101
176
  </h1>
102
- <p style={sub}>This invite link is missing information. Please use the link from your email.</p>
103
- <a href={loginHref} style={link}>
104
- Go to sign in
105
- </a>
177
+ <p style={sub}>{tr("forge.work_invite_accept.missing_body")}</p>
178
+ {signInLink}
106
179
  </div>
107
180
  );
108
181
  }
@@ -119,153 +192,140 @@ export function WorkInviteAccept({
119
192
  return (
120
193
  <div style={card} data-testid="work-invite-accept">
121
194
  <h1 style={heading} data-testid="work-invite-invalid">
122
- Invite not available
195
+ {tr("forge.work_invite_accept.unavailable_title")}
123
196
  </h1>
124
- <p style={sub}>This invite is invalid or has expired. Ask your provider to send a new one.</p>
125
- <a href={loginHref} style={link}>
126
- Go to sign in
127
- </a>
197
+ <p style={sub}>{tr("forge.work_invite_accept.unavailable_body")}</p>
198
+ {signInLink}
128
199
  </div>
129
200
  );
130
201
  }
131
202
 
132
203
  const invitedEmail = invite.email?.toLowerCase() ?? "";
133
204
  const currentEmail = (user?.email ?? "").toLowerCase();
134
- const signedInAsInvitee = isAuthenticated && currentEmail && currentEmail === invitedEmail;
205
+ const signedInAsInvitee = isAuthenticated && !!currentEmail && currentEmail === invitedEmail;
206
+ const signedInAsSomeoneElse = isAuthenticated && !!currentEmail && currentEmail !== invitedEmail;
135
207
 
136
- const header = (
208
+ const header: ReactNode = (
137
209
  <>
138
210
  <div style={eyebrow}>{invite.profileName}</div>
139
211
  <h1 style={heading} data-testid="work-invite-heading">
140
- You're invited to <span style={{ color: theme.colors.primary }}>{invite.projectName}</span>
212
+ {tr("forge.work_invite_accept.heading", { project_name: invite.projectName })}
141
213
  </h1>
142
214
  </>
143
215
  );
144
216
 
145
- // Already signed in AS the invited person → nothing to do but open the project.
217
+ const errorLine = error ? (
218
+ <p role="alert" style={{ margin: "0 0 14px", fontSize: 14, color: t.text, fontWeight: 600 }} data-testid="work-invite-error">
219
+ {error}
220
+ </p>
221
+ ) : null;
222
+
223
+ // Signed in AS the invited person: accepting links the project, then opens it.
146
224
  if (signedInAsInvitee) {
225
+ const onOpen = async () => {
226
+ setError("");
227
+ setSubmitting(true);
228
+ try {
229
+ await acceptInvite.mutateAsync({ token, projectId });
230
+ } catch (err) {
231
+ // 404: the invite was already used, and this account already has the
232
+ // project. Anything else is a real failure to show.
233
+ if (status(err) !== 404) {
234
+ setError(msg(err) || tr("forge.work_invite_accept.generic_error"));
235
+ setSubmitting(false);
236
+ return;
237
+ }
238
+ }
239
+ clearStash(projectId);
240
+ // Rides the session to the project page, where the site app offers itself.
241
+ requestAppInstall("invite_accepted");
242
+ window.location.assign(projectHref);
243
+ };
147
244
  return (
148
245
  <div style={card} data-testid="work-invite-accept">
149
246
  {header}
150
247
  <p style={sub}>
151
- <CheckCircle2 size={16} style={{ verticalAlign: "-2px", color: theme.colors.primary }} /> You're signed in as{" "}
152
- <strong>{invite.email}</strong>. You already have access.
248
+ <Rich text={tr("forge.work_invite_accept.signed_in_as_invitee")} values={{ email: invite.email }} />
153
249
  </p>
154
- <a href={projectHref} style={button} data-testid="work-invite-view-project">
155
- <FolderOpen size={16} /> View project
156
- </a>
250
+ {errorLine}
251
+ <button
252
+ type="button"
253
+ style={{ ...fullWidth, opacity: submitting ? 0.6 : 1 }}
254
+ disabled={submitting}
255
+ data-testid="work-invite-view-project"
256
+ onClick={onOpen}
257
+ >
258
+ <FolderOpen size={16} aria-hidden />{" "}
259
+ {submitting ? tr("forge.work_invite_accept.accepting") : tr("forge.work_invite_accept.view_project")}
260
+ </button>
157
261
  </div>
158
262
  );
159
263
  }
160
264
 
161
- /**
162
- * The invited address already has a TribeNest account (C10). An invite grants
163
- * portal ACCESS; it is not a credential reset for an account whose owner
164
- * never asked for one — so there is no password form here, only a sign-in
165
- * link. Accepting still runs, to consume the invite and link the contact.
166
- */
167
- if (invite.requiresExistingLogin) {
265
+ // Signed in as somebody else: the invite is for one address only.
266
+ if (signedInAsSomeoneElse) {
168
267
  return (
169
268
  <div style={card} data-testid="work-invite-accept">
170
269
  {header}
171
270
  <p style={sub}>
172
- <strong>{invite.email}</strong> already has an account. Sign in and the project will be waiting for you.
271
+ <Rich
272
+ text={tr("forge.work_invite_accept.signed_in_as_other")}
273
+ values={{ current_email: user?.email ?? "", invited_email: invite.email }}
274
+ />
173
275
  </p>
174
276
  <button
175
277
  type="button"
176
- style={button}
177
- disabled={submitting}
178
- data-testid="work-invite-signin"
278
+ style={fullWidth}
279
+ data-testid="work-invite-switch-account"
179
280
  onClick={async () => {
180
- setSubmitting(true);
181
- try {
182
- // Consumes the invite and links the contact; returns no session.
183
- await acceptInvite.mutateAsync({ token, projectId, password: "" });
184
- } catch {
185
- // Non-fatal: an already-consumed invite still leaves them able to
186
- // sign in, which is the whole instruction on this screen.
187
- }
188
- window.location.assign(loginHref);
281
+ await logout();
282
+ window.location.assign(invite.needsAccount ? withRedirect(signupHref) : withRedirect(loginHref));
189
283
  }}
190
284
  >
191
- Go to sign in
285
+ {tr("forge.work_invite_accept.switch_account", { email: invite.email })}
192
286
  </button>
193
287
  </div>
194
288
  );
195
289
  }
196
290
 
197
- const onAccept = async (e: React.FormEvent) => {
198
- e.preventDefault();
199
- setError("");
200
- if (password.length < 8) {
201
- setError("Password must be at least 8 characters.");
202
- return;
203
- }
204
- if (password !== confirm) {
205
- setError("Passwords don't match.");
206
- return;
207
- }
208
- setSubmitting(true);
209
- try {
210
- const result = await acceptInvite.mutateAsync({ token, projectId, password });
211
- if (result.requiresLogin || !result.token) {
212
- // The address turned out to already have an account — no session is
213
- // issued for it (C10). Send them to sign in.
214
- window.location.assign(loginHref);
215
- return;
216
- }
217
- // Persist the fresh portal session (access + rotating refresh token) and
218
- // hard-navigate so the auth context re-initializes as the invited client.
219
- localStorage.setItem(ACCESS_TOKEN_KEY, result.token);
220
- if (result.refreshToken) localStorage.setItem(PUBLIC_REFRESH_TOKEN_KEY, result.refreshToken);
221
- window.location.assign(projectHref);
222
- } catch (err) {
223
- setError(msg(err) || "Something went wrong. Please try again.");
224
- setSubmitting(false);
225
- }
226
- };
291
+ // Not signed in, and the address has no account: the normal signup first.
292
+ if (invite.needsAccount) {
293
+ return (
294
+ <div style={card} data-testid="work-invite-accept">
295
+ {header}
296
+ <p style={sub}>
297
+ <Rich text={tr("forge.work_invite_accept.create_account_intro")} values={{ email: invite.email }} />
298
+ </p>
299
+ <button
300
+ type="button"
301
+ style={fullWidth}
302
+ data-testid="work-invite-signup"
303
+ onClick={() => {
304
+ rememberSignupEmail(invite.email);
305
+ window.location.assign(withRedirect(signupHref));
306
+ }}
307
+ >
308
+ {tr("forge.work_invite_accept.create_account")}
309
+ </button>
310
+ <p style={{ ...sub, margin: "16px 0 0", fontSize: 14 }}>
311
+ <a href={withRedirect(loginHref)} style={buttons.quiet} data-testid="work-invite-signin">
312
+ {tr("forge.work_invite_accept.have_account")}
313
+ </a>
314
+ </p>
315
+ </div>
316
+ );
317
+ }
227
318
 
319
+ // Not signed in, and the address already has an account: sign in, come back.
228
320
  return (
229
321
  <div style={card} data-testid="work-invite-accept">
230
322
  {header}
231
323
  <p style={sub}>
232
- {isAuthenticated && currentEmail && currentEmail !== invitedEmail ? (
233
- <>
234
- You're signed in as <strong>{user?.email}</strong>, but this invite is for <strong>{invite.email}</strong>.
235
- Set a password to continue as {invite.email}.
236
- </>
237
- ) : (
238
- <>
239
- Set a password for <strong>{invite.email}</strong> to access your client portal.
240
- </>
241
- )}
324
+ <Rich text={tr("forge.work_invite_accept.existing_account")} values={{ email: invite.email }} />
242
325
  </p>
243
- {error && (
244
- <p style={{ color: "#ef4444", marginBottom: 14, fontSize: 14 }} data-testid="work-invite-error">
245
- {error}
246
- </p>
247
- )}
248
- <form onSubmit={onAccept} style={{ display: "flex", flexDirection: "column", gap: 14 }}>
249
- <input
250
- type="password"
251
- placeholder="Create a password"
252
- value={password}
253
- onChange={(e) => setPassword(e.target.value)}
254
- style={input}
255
- data-testid="work-invite-password"
256
- />
257
- <input
258
- type="password"
259
- placeholder="Confirm password"
260
- value={confirm}
261
- onChange={(e) => setConfirm(e.target.value)}
262
- style={input}
263
- data-testid="work-invite-confirm"
264
- />
265
- <button type="submit" disabled={submitting} style={button} data-testid="work-invite-submit">
266
- {submitting ? "Accepting…" : "Accept invite"}
267
- </button>
268
- </form>
326
+ <a href={withRedirect(loginHref)} style={fullWidth} data-testid="work-invite-signin">
327
+ {tr("forge.work_invite_accept.go_to_sign_in")}
328
+ </a>
269
329
  </div>
270
330
  );
271
331
  }