@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
@@ -4,6 +4,7 @@ import {
4
4
  useCommunityMentionDirectory,
5
5
  type CommunityLeaderboardEntry,
6
6
  } from "../../../data/queries/useCommunity";
7
+ import { useCommunityAgent } from "../../../data/queries/useCommunityAgent";
7
8
  import type { MentionSuggestion } from "./useMentionComposer";
8
9
  import { collectMentions, isRichEmpty, serializeRichToLexical } from "./richLexical";
9
10
 
@@ -35,7 +36,10 @@ export function useRichMentionComposer() {
35
36
  const [tick, setTick] = useState(0);
36
37
  const bump = useCallback(() => setTick((n) => n + 1), []);
37
38
 
38
- const { data: directory = [] } = useCommunityMentionDirectory();
39
+ // Searched on the server as the member types; nothing is asked for without an @token.
40
+ const { data: directory = [] } = useCommunityMentionDirectory(activeToken?.query);
41
+ // The community AI, listed first when it is on (community parity §8.2).
42
+ const { agent } = useCommunityAgent();
39
43
 
40
44
  const members: MentionSuggestion[] = useMemo(
41
45
  () =>
@@ -51,8 +55,17 @@ export function useRichMentionComposer() {
51
55
  const suggestions: MentionSuggestion[] = useMemo(() => {
52
56
  if (!activeToken) return [];
53
57
  const q = activeToken.query.toLowerCase();
54
- return members.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 6);
55
- }, [activeToken, members]);
58
+ const matched = members.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 6);
59
+ if (!agent || !agent.name.toLowerCase().includes(q)) return matched;
60
+ const agentSuggestion: MentionSuggestion = {
61
+ accountId: "",
62
+ agentId: agent.id,
63
+ name: agent.name,
64
+ avatarUrl: agent.avatarUrl,
65
+ level: 0,
66
+ };
67
+ return [agentSuggestion, ...matched];
68
+ }, [activeToken, members, agent]);
56
69
 
57
70
  /** Recompute the active @query token from the current selection. */
58
71
  const detectToken = useCallback(() => {
@@ -74,7 +87,7 @@ export function useRichMentionComposer() {
74
87
  setActiveToken({ query, textNode, atOffset: caretOffset - query.length - 1, caretOffset });
75
88
  }, []);
76
89
 
77
- /** Fired on input/keyup/click/select — refresh emptiness + token. */
90
+ /** Fired on input/keyup/click/select: refresh emptiness + token. */
78
91
  const onEditorChange = useCallback(() => {
79
92
  bump();
80
93
  detectToken();
@@ -124,7 +137,8 @@ export function useRichMentionComposer() {
124
137
  range.deleteContents();
125
138
 
126
139
  const chip = doc.createElement("span");
127
- chip.setAttribute("data-account-id", member.accountId);
140
+ if (member.agentId) chip.setAttribute("data-agent-id", member.agentId);
141
+ else chip.setAttribute("data-account-id", member.accountId);
128
142
  chip.setAttribute("data-mention-name", member.name);
129
143
  chip.setAttribute("contenteditable", "false");
130
144
  chip.textContent = `@${member.name}`;
@@ -184,6 +198,8 @@ export function useRichMentionComposer() {
184
198
  isSuggesting: !!activeToken && suggestions.length > 0,
185
199
  insertMention,
186
200
  dismissSuggestions: () => setActiveToken(null),
201
+ /** The community AI when it is on and can be mentioned, else null. */
202
+ agent,
187
203
  // Submission / state
188
204
  isEmpty,
189
205
  getBody,
@@ -0,0 +1,8 @@
1
+ // The download page behind a digital delivery email (`/i/downloads/$token`).
2
+ export {
3
+ useDownloadPage,
4
+ type DownloadItem,
5
+ type DownloadFile,
6
+ type DownloadFileInfo,
7
+ type DownloadPageInfo,
8
+ } from "./useDownloadPage";
@@ -0,0 +1,134 @@
1
+ "use client";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useMutation, useQuery } from "@tanstack/react-query";
4
+ import { useForge } from "../../../provider/ForgeProvider";
5
+ import { usePublicAuth } from "../../../contexts/PublicAuthContext";
6
+ import { apiErrorMessage } from "../coupon/useCouponField";
7
+
8
+ /**
9
+ * The download page behind a delivery email (`/i/downloads/$token`).
10
+ *
11
+ * The token in the link finds the order and opens nothing. The files come out
12
+ * to a signed-in owner straight away, or to whoever types the code we email to
13
+ * the recipient. A right code also returns a GRANT, a signed one-hour pass for
14
+ * this delivery, held here in memory so a second click does not need a second
15
+ * email. Every file address is a ten-minute signed read, so they are fetched
16
+ * again (with the grant or the session) once they are close to expiring.
17
+ */
18
+
19
+ export interface DownloadItem {
20
+ title: string;
21
+ quantity: number;
22
+ coverImage: string | null;
23
+ }
24
+
25
+ export interface DownloadFileInfo {
26
+ id: string;
27
+ filename: string;
28
+ size: number | null;
29
+ }
30
+
31
+ export interface DownloadPageInfo {
32
+ items: DownloadItem[];
33
+ files: DownloadFileInfo[];
34
+ /** The recipient's address, masked: `b***@example.com`. */
35
+ sentTo: string;
36
+ isGift: boolean;
37
+ signedInAsOwner: boolean;
38
+ }
39
+
40
+ export interface DownloadFile extends DownloadFileInfo {
41
+ /** A signed read, valid for ten minutes from when it was fetched. */
42
+ url: string;
43
+ }
44
+
45
+ type FilesResponse = { files: DownloadFile[]; grant?: string };
46
+
47
+ /** Fetch again a minute before the ten-minute signature runs out. */
48
+ const URL_REFRESH_AFTER_MS = 9 * 60 * 1000;
49
+
50
+ export function useDownloadPage(token: string) {
51
+ const { client, profileId } = useForge();
52
+ const { user } = usePublicAuth();
53
+ const grant = useRef<string | null>(null);
54
+ const [files, setFiles] = useState<DownloadFile[] | null>(null);
55
+ const [fetchedAt, setFetchedAt] = useState(0);
56
+ const [codeSentTo, setCodeSentTo] = useState<string | null>(null);
57
+
58
+ const page = useQuery({
59
+ queryKey: ["download-page", profileId, token, user?.id ?? null],
60
+ queryFn: async () => {
61
+ const res = await client.get<DownloadPageInfo>(`/public/downloads/${encodeURIComponent(token)}`, {
62
+ params: { profileId },
63
+ });
64
+ return res.data;
65
+ },
66
+ retry: false,
67
+ enabled: !!token && !!profileId,
68
+ });
69
+
70
+ const unlock = useMutation({
71
+ mutationFn: async (input: { code?: string }) => {
72
+ const res = await client.post<FilesResponse>(`/public/downloads/${encodeURIComponent(token)}/files`, {
73
+ profileId,
74
+ ...(input.code ? { code: input.code } : {}),
75
+ ...(!input.code && grant.current ? { grant: grant.current } : {}),
76
+ });
77
+ return res.data;
78
+ },
79
+ onSuccess: (data) => {
80
+ if (data.grant) grant.current = data.grant;
81
+ setFiles(data.files);
82
+ setFetchedAt(Date.now());
83
+ },
84
+ });
85
+
86
+ const requestCode = useMutation({
87
+ mutationFn: async () => {
88
+ const res = await client.post<{ sentTo: string }>(`/public/downloads/${encodeURIComponent(token)}/code`, {
89
+ profileId,
90
+ });
91
+ return res.data;
92
+ },
93
+ onSuccess: (data) => setCodeSentTo(data.sentTo),
94
+ });
95
+
96
+ // A signed-in owner has nothing to prove: fetch the files on arrival.
97
+ const signedInAsOwner = !!page.data?.signedInAsOwner;
98
+ const { mutate: unlockMutate } = unlock;
99
+ useEffect(() => {
100
+ if (signedInAsOwner && !files) unlockMutate({});
101
+ }, [signedInAsOwner, files, unlockMutate]);
102
+
103
+ /**
104
+ * The address to send the browser to for one file, re-fetching the set first
105
+ * when the signatures are about to lapse.
106
+ */
107
+ const urlFor = useCallback(
108
+ async (fileId: string): Promise<string | null> => {
109
+ let current = files;
110
+ if (!current || Date.now() - fetchedAt > URL_REFRESH_AFTER_MS) {
111
+ current = (await unlock.mutateAsync({})).files;
112
+ }
113
+ return current.find((f) => f.id === fileId)?.url ?? null;
114
+ },
115
+ [files, fetchedAt, unlock],
116
+ );
117
+
118
+ const status: "loading" | "missing" | "locked" | "unlocked" =
119
+ page.isLoading ? "loading" : page.isError || !page.data ? "missing" : files ? "unlocked" : "locked";
120
+
121
+ return {
122
+ status,
123
+ info: page.data ?? null,
124
+ files,
125
+ codeSentTo,
126
+ requestCode: () => requestCode.mutate(),
127
+ isSendingCode: requestCode.isPending,
128
+ sendCodeError: requestCode.error ? (apiErrorMessage(requestCode.error) ?? null) : null,
129
+ submitCode: (code: string) => unlock.mutate({ code }),
130
+ isUnlocking: unlock.isPending,
131
+ unlockError: unlock.error ? (apiErrorMessage(unlock.error) ?? null) : null,
132
+ urlFor,
133
+ };
134
+ }
@@ -61,6 +61,12 @@ export {
61
61
  } from "./checkout/inventoryHold";
62
62
  export { useLoginFlow, type LoginStep } from "./auth/useLoginFlow";
63
63
  export { useSignupForm, type UseSignupFormOptions } from "./auth/useSignupForm";
64
+ // Email-first sign-in: one email field and a code; a name only for someone new.
65
+ export {
66
+ useEmailCodeSignIn,
67
+ type EmailCodeSignInStep,
68
+ type UseEmailCodeSignInOptions,
69
+ } from "./auth/useEmailCodeSignIn";
64
70
 
65
71
  // Flow primitives (Part A2): multi-step purchase/booking/subscribe/chat flows,
66
72
  // each composed from the data hooks so a creator can rebuild any page.
@@ -200,6 +206,13 @@ export * from "./document";
200
206
  export * from "./film";
201
207
  // Work (project management) client-portal headless primitives.
202
208
  export * from "./work";
209
+ // The client portal (`/i/account/*`): summary, invoices, documents, activity, approvals.
210
+ export * from "./portal";
211
+ // The download page behind a digital delivery email (`/i/downloads/$token`).
212
+ export * from "./downloads";
213
+ // The site app: its bottom bar model, its home sections, and its install and
214
+ // notification prompts (`requestAppInstall`).
215
+ export * from "./siteApp";
203
216
  // Funnel instrumentation: wrap a multi-step flow to record step-through and
204
217
  // drop-off on the first-party analytics feed.
205
218
  export * from "./funnel";
@@ -5,6 +5,16 @@ export {
5
5
  type MemberHomeNavKey,
6
6
  type UseMemberHomeNavOptions,
7
7
  } from "./useMemberHomeNav";
8
+ // The phone's bottom bar and its "More" sheet, decided from the nav model.
9
+ export {
10
+ buildMemberHomeBar,
11
+ MEMBER_HOME_BAR_PRIORITY,
12
+ MEMBER_HOME_BAR_SLOTS,
13
+ MEMBER_HOME_MORE_GROUPS,
14
+ type MemberHomeBarInput,
15
+ type MemberHomeBarModel,
16
+ type MemberHomeMoreGroup,
17
+ } from "./memberHomeBar";
8
18
 
9
19
  // The member call calendar's model. Grouping lives here so the two rendering
10
20
  // surfaces cannot disagree about which calls are "today".
@@ -0,0 +1,91 @@
1
+ import type { MemberHomeNavKey } from "./useMemberHomeNav";
2
+
3
+ /**
4
+ * The phone layout of a community's member home: a bottom bar of four slots,
5
+ * then "More", which opens a sheet with everything else. Kept apart from how it
6
+ * is drawn so the bar, the sheet and a spec can never disagree about what goes
7
+ * where.
8
+ */
9
+
10
+ /** How many items the bar holds before "More". */
11
+ export const MEMBER_HOME_BAR_SLOTS = 4;
12
+
13
+ /**
14
+ * Who gets a slot, in order. The first four are the ones a member opens daily;
15
+ * when one of them is not shown for this community, the next shown item in this
16
+ * list moves up into its slot.
17
+ */
18
+ export const MEMBER_HOME_BAR_PRIORITY: readonly MemberHomeNavKey[] = [
19
+ "posts",
20
+ "spaces",
21
+ "chat",
22
+ "notifications",
23
+ "calendar",
24
+ "classroom",
25
+ "members",
26
+ "wiki",
27
+ "collections",
28
+ "leaderboard",
29
+ "moderation",
30
+ ];
31
+
32
+ /** The "More" sheet's groups and the order inside each. */
33
+ export const MEMBER_HOME_MORE_GROUPS: Readonly<Record<MemberHomeMoreGroup, readonly MemberHomeNavKey[]>> = {
34
+ community: ["collections", "calendar", "classroom", "wiki", "members", "leaderboard"],
35
+ you: ["moderation"],
36
+ };
37
+
38
+ export type MemberHomeMoreGroup = "community" | "you";
39
+
40
+ /** The least a nav item needs for the bar model: its key, whether it is shown, and whether it is the page. */
41
+ export interface MemberHomeBarInput<T extends { key: MemberHomeNavKey }> {
42
+ /** The items shown for this community (`useMemberHomeNav().visibleItems`). */
43
+ visibleItems: readonly T[];
44
+ /** The page being shown, or null. */
45
+ activeKey: MemberHomeNavKey | null;
46
+ }
47
+
48
+ export interface MemberHomeBarModel<T extends { key: MemberHomeNavKey }> {
49
+ /** Up to four items for the bar, in slot order. */
50
+ bar: T[];
51
+ /** Everything else that is shown, grouped for the sheet. Empty groups are left out. */
52
+ more: Array<{ group: MemberHomeMoreGroup; items: T[] }>;
53
+ /** The page being shown lives in the sheet, so "More" is the highlighted button. */
54
+ moreActive: boolean;
55
+ }
56
+
57
+ /**
58
+ * Decide what sits in the bar and what goes behind "More". Pure.
59
+ *
60
+ * An item is never in both places, and every shown item is in one of them: a
61
+ * key no group names (none today) lands at the end of "Community" rather than
62
+ * disappearing.
63
+ */
64
+ export function buildMemberHomeBar<T extends { key: MemberHomeNavKey }>({
65
+ visibleItems,
66
+ activeKey,
67
+ }: MemberHomeBarInput<T>): MemberHomeBarModel<T> {
68
+ const byKey = new Map(visibleItems.map((item) => [item.key, item] as const));
69
+ const bar = MEMBER_HOME_BAR_PRIORITY.filter((key) => byKey.has(key))
70
+ .slice(0, MEMBER_HOME_BAR_SLOTS)
71
+ .map((key) => byKey.get(key)!);
72
+ const inBar = new Set(bar.map((item) => item.key));
73
+ const rest = visibleItems.filter((item) => !inBar.has(item.key));
74
+
75
+ const placed = new Set<MemberHomeNavKey>();
76
+ const more = (Object.keys(MEMBER_HOME_MORE_GROUPS) as MemberHomeMoreGroup[]).map((group) => {
77
+ const items = MEMBER_HOME_MORE_GROUPS[group]
78
+ .map((key) => rest.find((item) => item.key === key))
79
+ .filter((item): item is T => !!item);
80
+ items.forEach((item) => placed.add(item.key));
81
+ return { group, items };
82
+ });
83
+ const unplaced = rest.filter((item) => !placed.has(item.key));
84
+ if (unplaced.length) more[0]!.items.push(...unplaced);
85
+
86
+ return {
87
+ bar,
88
+ more: more.filter((g) => g.items.length > 0),
89
+ moreActive: !!activeKey && !inBar.has(activeKey) && rest.some((item) => item.key === activeKey),
90
+ };
91
+ }
@@ -1,19 +1,38 @@
1
- import { useMemo } from "react";
1
+ import { useContext, useMemo } from "react";
2
2
  import { useGetPostCollections } from "../../../data/queries/usePostCollections";
3
- import { useCommunitySpaces } from "../../../data/queries/useCommunity";
3
+ import { useCommunityCanModerate, useCommunitySpaces } from "../../../data/queries/useCommunity";
4
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
5
+ import { useCommunityWikiPages } from "../../../data/queries/useCommunityWiki";
6
+ import { useCommunityViewerState } from "../../../data/queries/useCommunities";
7
+ import { viewerHasAccess } from "../communities/useCommunity";
4
8
  import { useChatChannels } from "../../../data/queries/useChat";
5
- import { useMyMembershipCalls } from "../../../data/queries/useMembershipCalls";
6
9
  import { MissingCommunityProviderError, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
7
10
  import { internalPath } from "../../../runtime/pages";
8
11
 
9
- export type MemberHomeNavKey = "posts" | "collections" | "spaces" | "calendar" | "chat" | "leaderboard" | "notifications";
12
+ export type MemberHomeNavKey =
13
+ | "posts"
14
+ | "collections"
15
+ | "spaces"
16
+ | "calendar"
17
+ | "classroom"
18
+ | "chat"
19
+ | "leaderboard"
20
+ | "notifications"
21
+ | "members"
22
+ | "wiki"
23
+ | "moderation";
10
24
 
11
25
  export interface MemberHomeNavItem {
12
26
  key: MemberHomeNavKey;
13
27
  label: string;
14
28
  /** Absolute in-site href (already run through `buildHref` when supplied). */
15
29
  href: string;
16
- /** Conditional tabs (Collections/Spaces/Calendar/Chat/Leaderboard/Notifications) hide until the community has content for them. */
30
+ /**
31
+ * Conditional tabs (Collections/Spaces/Calendar/Chat/Wiki/Leaderboard/Notifications)
32
+ * hide until the community has content for them. Members, Calendar and
33
+ * Classroom show to members (the server answers them only). Moderation shows only to
34
+ * the community's moderators (and the profile's owners and admins).
35
+ */
17
36
  visible: boolean;
18
37
  /** Whether `currentPath` (or `activeKey`) falls on this tab. */
19
38
  active: boolean;
@@ -46,8 +65,8 @@ const normalize = (p: string) => {
46
65
  * queries the pages already need (cache shared via React Query), all scoped to
47
66
  * the surrounding `<CommunityProvider>`: Posts is always visible; Collections
48
67
  * when the community has any; Spaces (with its Leaderboard and Notifications)
49
- * when any space is accessible; Chat when any channel is; Calendar when any
50
- * call is. Billing, cancel and downgrade live in the community header's
68
+ * when any space is accessible; Chat when any channel is. Calendar, Classroom
69
+ * and Members show to members, whose reads they are. Billing, cancel and downgrade live in the community header's
51
70
  * Membership menu, not here, and nothing links to the account page. Hosts own
52
71
  * routing: pair with any layout via `href`/`active`.
53
72
  */
@@ -63,19 +82,29 @@ export function useMemberHomeNav(opts: UseMemberHomeNavOptions = {}) {
63
82
  const spacesQ = useCommunitySpaces();
64
83
  const channelsQ = useChatChannels();
65
84
  /**
66
- * ANY call this viewer may see in this community, ever - not just an
67
- * upcoming one. The Calendar is a month you can page backwards through, so
68
- * it has something to show from the moment the creator has ever run a call.
69
- * Keying the tab on upcoming calls alone hid the whole history the hour the
70
- * last one ended, which is precisely when somebody goes looking for the
71
- * recording. `limit: 1` because this is an existence check.
85
+ * The Moderation tab is for this community's moderators only. Display only:
86
+ * every moderation call is checked again by the server. Signed-out viewers
87
+ * never ask (read without `usePublicAuth()`, which throws outside a provider).
88
+ */
89
+ const signedIn = !!useContext(PublicAuthContext)?.user;
90
+ const canModerate = useCommunityCanModerate(signedIn);
91
+ /** The wiki, once the community has any page this viewer may see listed. */
92
+ const wikiQ = useCommunityWikiPages();
93
+ const hasWiki = Array.isArray(wikiQ.data) && wikiQ.data.length > 0;
94
+ /**
95
+ * The member directory, the calendar and the classroom are for members (the
96
+ * server answers 403 to anyone else). The calendar lists calls AND the events
97
+ * promoted into the community, and is a month you can page through, so it is
98
+ * a member's tab from the start rather than one that waits for a call. The
99
+ * viewer state is the same read the community header makes, so this adds no
100
+ * request there.
72
101
  */
73
- const callsQ = useMyMembershipCalls({ from: new Date(0), limit: 1 });
102
+ const viewerQ = useCommunityViewerState(community.ref, { enabled: signedIn });
103
+ const isMember = signedIn && (viewerHasAccess(viewerQ.data) || canModerate);
74
104
 
75
105
  const hasCollections = (collectionsQ.data?.data?.length ?? 0) > 0;
76
106
  const hasSpaces = (spacesQ.data?.length ?? 0) > 0;
77
107
  const hasChannels = (channelsQ.data?.length ?? 0) > 0;
78
- const hasCalls = (callsQ.data?.length ?? 0) > 0;
79
108
 
80
109
  const items: MemberHomeNavItem[] = useMemo(() => {
81
110
  const isUnder = (segment: string) =>
@@ -88,14 +117,18 @@ export function useMemberHomeNav(opts: UseMemberHomeNavOptions = {}) {
88
117
  { key: "spaces", label: "Spaces", path: `${basePath}/spaces`, visible: hasSpaces, external: false, active: onKey("spaces", isUnder("spaces") || isUnder("profile")) },
89
118
  // Scheduled calls. Sits above Chat because a call has a TIME on it: it is
90
119
  // the one tab whose content stops being useful if it is found late.
91
- { key: "calendar", label: "Calendar", path: `${basePath}/calendar`, visible: hasCalls, external: false, active: onKey("calendar", isUnder("calendar")) },
120
+ { key: "calendar", label: "Calendar", path: `${basePath}/calendar`, visible: isMember, external: false, active: onKey("calendar", isUnder("calendar")) },
121
+ { key: "classroom", label: "Classroom", path: `${basePath}/classroom`, visible: isMember, external: false, active: onKey("classroom", isUnder("classroom")) },
92
122
  { key: "chat", label: "Chat", path: `${basePath}/chat`, visible: hasChannels, external: false, active: onKey("chat", isUnder("chat")) },
93
123
  { key: "leaderboard", label: "Leaderboard", path: `${basePath}/leaderboard`, visible: hasSpaces, external: false, active: onKey("leaderboard", isUnder("leaderboard")) },
94
124
  { key: "notifications", label: "Notifications", path: `${basePath}/notifications`, visible: hasSpaces, external: false, active: onKey("notifications", isUnder("notifications")) },
125
+ { key: "members", label: "Members", path: `${basePath}/members`, visible: isMember, external: false, active: onKey("members", isUnder("members")) },
126
+ { key: "wiki", label: "Wiki", path: `${basePath}/wiki`, visible: hasWiki, external: false, active: onKey("wiki", isUnder("wiki")) },
127
+ { key: "moderation", label: "Moderation", path: `${basePath}/moderation`, visible: canModerate, external: false, active: onKey("moderation", isUnder("moderation")) },
95
128
  ];
96
129
  return defs.map(({ path, ...d }) => ({ ...d, href: buildHref(path) }));
97
130
  // eslint-disable-next-line react-hooks/exhaustive-deps
98
- }, [basePath, currentPath, opts.activeKey, hasCollections, hasSpaces, hasCalls, hasChannels]);
131
+ }, [basePath, currentPath, opts.activeKey, hasCollections, hasSpaces, hasChannels, hasWiki, isMember, canModerate]);
99
132
 
100
133
  return {
101
134
  items,
@@ -103,6 +136,11 @@ export function useMemberHomeNav(opts: UseMemberHomeNavOptions = {}) {
103
136
  visibleItems: items.filter((i) => i.visible),
104
137
  activeKey: items.find((i) => i.active)?.key ?? null,
105
138
  /** True once every visibility query has settled (avoids tab flash). */
106
- isReady: !!basePath && !collectionsQ.isLoading && !spacesQ.isLoading && !callsQ.isLoading && !channelsQ.isLoading,
139
+ isReady:
140
+ !!basePath &&
141
+ !collectionsQ.isLoading &&
142
+ !spacesQ.isLoading &&
143
+ !channelsQ.isLoading &&
144
+ !wikiQ.isLoading,
107
145
  };
108
146
  }
@@ -0,0 +1,57 @@
1
+ // Client portal (`/i/account/*`) headless primitives.
2
+ export {
3
+ usePortalSummary,
4
+ usePortalInvoices,
5
+ usePortalDocuments,
6
+ usePortalActivity,
7
+ useWorkApprovalRequests,
8
+ useDecideApproval,
9
+ type PortalSectionKey,
10
+ type PortalAttentionItem,
11
+ type PortalSummary,
12
+ type PortalInvoice,
13
+ type PortalDocument,
14
+ type PortalActivityKind,
15
+ type PortalActivityItem,
16
+ type PortalActivityPage,
17
+ type ApprovalStatus,
18
+ type WorkApprovalRequest,
19
+ type WorkApprovalRequests,
20
+ type ApprovalDecision,
21
+ } from "./usePortal";
22
+ export {
23
+ usePortalFiles,
24
+ portalFilesKey,
25
+ type PortalFile,
26
+ type PortalFileOpen,
27
+ type PortalFilesPage,
28
+ type PortalFileSource,
29
+ type PortalFileSourceFilter,
30
+ } from "./usePortalFiles";
31
+ // Preview as client: redeem the one-time code, and read whether this is a preview.
32
+ export {
33
+ usePortalPreview,
34
+ usePortalPreviewRedeem,
35
+ isPortalPreviewPending,
36
+ type PortalPreview,
37
+ type PortalPreviewRedeemState,
38
+ } from "./usePortalPreview";
39
+ // Messages: the one conversation with the creator.
40
+ export {
41
+ usePortalThread,
42
+ usePortalMessages,
43
+ useSendPortalMessage,
44
+ useMarkPortalMessagesRead,
45
+ usePortalMessageAttachments,
46
+ usePortalMessagesLive,
47
+ portalMessagesInOrder,
48
+ portalThreadKey,
49
+ portalMessagesKey,
50
+ PORTAL_MESSAGES_POLL_MS,
51
+ PORTAL_MESSAGES_PRESIGN_PATH,
52
+ type PortalMessage,
53
+ type PortalMessageAttachment,
54
+ type PortalMessagesPage,
55
+ type PortalThreadState,
56
+ type PortalThreadSummary,
57
+ } from "./usePortalMessages";