@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
@@ -0,0 +1,247 @@
1
+ import { useMemo } from "react";
2
+ import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
3
+ import { useCommunityContext, useCommunityScope } from "../../contexts/CommunityContext";
4
+ import { useCommunityViewerState, useViewerAccountId } from "./useCommunities";
5
+ import { chatKeys, type ChatChannel } from "./useChat";
6
+
7
+ /** Viewer states that hold member access (`past_due` keeps it through the grace window). */
8
+ const MEMBER_STATES = ["member", "past_due"];
9
+
10
+ // ── Types (mirror apps/backend/src/routes/public/communityAgent) ─────────────
11
+
12
+ /**
13
+ * The community AI as a member's page sees it (community parity §8.1). Each
14
+ * community has at most one. It is not a member: it earns no points, is never
15
+ * in the directory, and is always shown with an "AI" badge.
16
+ */
17
+ export type CommunityAgentInfo =
18
+ | { enabled: false }
19
+ | {
20
+ enabled: true;
21
+ id: string;
22
+ name: string;
23
+ avatarUrl: string | null;
24
+ /** Whether members may open a 1:1 DM with it. */
25
+ dmEnabled: boolean;
26
+ };
27
+
28
+ /** The agent as the author of a post, reply or chat message. */
29
+ export interface CommunityAgentAuthor {
30
+ id: string;
31
+ name: string;
32
+ avatarUrl: string | null;
33
+ }
34
+
35
+ /** What opening the agent DM answers: the DM channel and the agent. */
36
+ export interface CommunityAgentDm {
37
+ channelId: string;
38
+ agent: CommunityAgentInfo;
39
+ }
40
+
41
+ /** The entity a thumbs up / down rates. */
42
+ export type CommunityAgentFeedbackEntity = "community_reply" | "chat_message";
43
+
44
+ /** 1 up, -1 down, 0 clears the rating. */
45
+ export type CommunityAgentFeedbackValue = 1 | -1 | 0;
46
+
47
+ /** A private "catch me up" summary. Shown to the member who asked, never posted. */
48
+ export interface CommunityAgentSummary {
49
+ summary: string;
50
+ agentName: string;
51
+ }
52
+
53
+ /** Why a "catch me up" failed, for the copy the panel shows. */
54
+ export type CommunityAgentSummaryError = "daily_limit" | "unavailable" | "failed";
55
+
56
+ export const communityAgentKeys = {
57
+ info: (profileId?: string, communityId?: string, accountId?: string | null) =>
58
+ ["public-community-agent", profileId, communityId, accountId ?? null] as const,
59
+ /** Every ratings read for one account and entity type: the prefix a new rating patches. */
60
+ ratingsFor: (profileId?: string, accountId?: string | null, entityType?: CommunityAgentFeedbackEntity) =>
61
+ ["public-community-agent-feedback", profileId, accountId ?? null, entityType] as const,
62
+ /** One visible list's ratings. `entityIds` must be sorted (see `useCommunityAgentRatings`). */
63
+ ratings: (
64
+ profileId: string | undefined,
65
+ accountId: string | null | undefined,
66
+ entityType: CommunityAgentFeedbackEntity,
67
+ entityIds: string[],
68
+ ) => ["public-community-agent-feedback", profileId, accountId ?? null, entityType, entityIds.join(",")] as const,
69
+ };
70
+
71
+ /** One row of the member's own ratings: an answer they asked for, and how they rated it. */
72
+ export interface CommunityAgentRating {
73
+ entityId: string;
74
+ feedback: 1 | -1 | null;
75
+ }
76
+
77
+ /** The ratings read takes at most this many ids per request. */
78
+ export const COMMUNITY_AGENT_RATINGS_BATCH = 100;
79
+
80
+ /**
81
+ * Put a rating into every loaded ratings read for that account and type, so
82
+ * every list showing the answer agrees with what was just saved. Only rows
83
+ * already there are patched: a read that did not return the answer means the
84
+ * viewer did not ask it, and a rating never adds it.
85
+ */
86
+ export function setCommunityAgentRating(
87
+ queryClient: QueryClient,
88
+ target: { profileId?: string; accountId?: string | null; entityType: CommunityAgentFeedbackEntity },
89
+ rating: CommunityAgentRating,
90
+ ) {
91
+ queryClient.setQueriesData<CommunityAgentRating[]>(
92
+ { queryKey: communityAgentKeys.ratingsFor(target.profileId, target.accountId, target.entityType) },
93
+ (old) =>
94
+ old?.some((row) => row.entityId === rating.entityId)
95
+ ? old.map((row) => (row.entityId === rating.entityId ? { ...row, feedback: rating.feedback } : row))
96
+ : old,
97
+ );
98
+ }
99
+
100
+ /** The prefix every agent DM's `dmKey` carries (`agent:<agentId>:<accountId>`). */
101
+ export const COMMUNITY_AGENT_DM_KEY_PREFIX = "agent:";
102
+
103
+ /** True for the member's DM with the community AI. */
104
+ export function isCommunityAgentDm(channel: Pick<ChatChannel, "type" | "dmKey"> | null | undefined): boolean {
105
+ return !!channel && channel.type === "dm" && !!channel.dmKey && channel.dmKey.startsWith(COMMUNITY_AGENT_DM_KEY_PREFIX);
106
+ }
107
+
108
+ /** The agent when it is switched on, or null. Narrowing helper for callers. */
109
+ export function enabledCommunityAgent(
110
+ info: CommunityAgentInfo | null | undefined,
111
+ ): Extract<CommunityAgentInfo, { enabled: true }> | null {
112
+ return info && info.enabled ? info : null;
113
+ }
114
+
115
+ /** The HTTP status of a failed request, if any. */
116
+ const statusOf = (error: unknown): number | undefined =>
117
+ (error as { response?: { status?: number } } | null)?.response?.status;
118
+
119
+ /** Map a failed "catch me up" to the copy the panel shows. */
120
+ export function communityAgentSummaryError(error: unknown): CommunityAgentSummaryError {
121
+ const status = statusOf(error);
122
+ if (status === 409) return "daily_limit";
123
+ if (status === 400) return "unavailable";
124
+ return "failed";
125
+ }
126
+
127
+ // ── Data hooks ───────────────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * The surrounding community's AI (GET /public/community-agent). Asked for only
131
+ * when the viewer is signed in AND a member of this community: nobody else can
132
+ * reach it, so nothing is requested for them. `data` is `{ enabled: false }`
133
+ * when the creator has it off. Pass `enabled: false` to hold the request.
134
+ */
135
+ export function useCommunityAgent(opts: { enabled?: boolean } = {}) {
136
+ const { client, profileId, communityId } = useCommunityScope("useCommunityAgent()");
137
+ const { ref } = useCommunityContext();
138
+ const accountId = useViewerAccountId();
139
+ const signedIn = !!accountId;
140
+ // Same cache entry `useCommunity()` reads, so inside a community home this
141
+ // costs no extra request.
142
+ const viewer = useCommunityViewerState(ref, { enabled: signedIn && opts.enabled !== false });
143
+ const isMember = signedIn && !!viewer.data && MEMBER_STATES.includes(viewer.data.state);
144
+ const query = useQuery<CommunityAgentInfo>({
145
+ queryKey: communityAgentKeys.info(profileId, communityId, accountId),
146
+ queryFn: async () => {
147
+ const res = await client.get("/public/community-agent", { params: { profileId, communityId } });
148
+ return res.data as CommunityAgentInfo;
149
+ },
150
+ enabled: opts.enabled !== false && isMember && !!profileId && !!communityId && !!client,
151
+ staleTime: 60_000,
152
+ });
153
+ return { ...query, agent: enabledCommunityAgent(query.data) };
154
+ }
155
+
156
+ /**
157
+ * Open (or reopen) the member's DM with the agent (POST /public/community-agent/dm).
158
+ * Answers `{ channelId, agent }`; the channel is an ordinary chat DM, so the
159
+ * channel list is refreshed to carry it. 404 when the agent or its DMs are off,
160
+ * 403 for a team member or a non-member.
161
+ */
162
+ export function useOpenCommunityAgentDm() {
163
+ const { client, profileId, communityId } = useCommunityScope("useOpenCommunityAgentDm()");
164
+ const queryClient = useQueryClient();
165
+ return useMutation({
166
+ mutationFn: async () => {
167
+ const res = await client.post("/public/community-agent/dm", { profileId, communityId });
168
+ return res.data as CommunityAgentDm;
169
+ },
170
+ onSuccess: () => {
171
+ queryClient.invalidateQueries({ queryKey: chatKeys.channels(communityId) });
172
+ },
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Thumbs up / down on an answer (POST /public/community-agent/feedback). Only
178
+ * the member who asked can rate it; anyone else gets a 404.
179
+ */
180
+ export function useCommunityAgentFeedback() {
181
+ const { client, profileId } = useCommunityScope("useCommunityAgentFeedback()");
182
+ const accountId = useViewerAccountId();
183
+ const queryClient = useQueryClient();
184
+ return useMutation({
185
+ mutationFn: async (input: {
186
+ entityType: CommunityAgentFeedbackEntity;
187
+ entityId: string;
188
+ value: CommunityAgentFeedbackValue;
189
+ }) => {
190
+ const res = await client.post("/public/community-agent/feedback", { profileId, ...input });
191
+ return res.data as { id: string; feedback: 1 | -1 | null };
192
+ },
193
+ onSuccess: (saved, input) => {
194
+ setCommunityAgentRating(
195
+ queryClient,
196
+ { profileId, accountId, entityType: input.entityType },
197
+ { entityId: input.entityId, feedback: saved?.feedback ?? (input.value === 0 ? null : input.value) },
198
+ );
199
+ },
200
+ });
201
+ }
202
+
203
+ /**
204
+ * The viewer's own ratings on the AI answers a list shows
205
+ * (GET /public/community-agent/feedback). Only answers THIS account asked for
206
+ * come back; anyone else's are simply absent, which is what decides who is
207
+ * offered thumbs at all. One read per list and type, keyed by the sorted ids,
208
+ * batched by 100. Asked only when signed in, a member, and the AI is on.
209
+ */
210
+ export function useCommunityAgentRatings(entityType: CommunityAgentFeedbackEntity, entityIds: string[]) {
211
+ const { client, profileId } = useCommunityScope("useCommunityAgentRatings()");
212
+ const accountId = useViewerAccountId();
213
+ const { agent } = useCommunityAgent();
214
+ const ids = useMemo(() => [...new Set(entityIds.filter(Boolean))].sort(), [entityIds]);
215
+ return useQuery<CommunityAgentRating[]>({
216
+ queryKey: communityAgentKeys.ratings(profileId, accountId, entityType, ids),
217
+ queryFn: async () => {
218
+ const rows: CommunityAgentRating[] = [];
219
+ for (let i = 0; i < ids.length; i += COMMUNITY_AGENT_RATINGS_BATCH) {
220
+ const batch = ids.slice(i, i + COMMUNITY_AGENT_RATINGS_BATCH);
221
+ const res = await client.get("/public/community-agent/feedback", {
222
+ params: { profileId, entityType, entityIds: batch.join(",") },
223
+ });
224
+ rows.push(...(((res.data as { data?: CommunityAgentRating[] })?.data ?? []) as CommunityAgentRating[]));
225
+ }
226
+ return rows;
227
+ },
228
+ enabled: !!agent && !!accountId && ids.length > 0 && !!profileId && !!client,
229
+ staleTime: 60_000,
230
+ });
231
+ }
232
+
233
+ /**
234
+ * "Catch me up" on a long thread (POST /public/community-agent/posts/:id/summary).
235
+ * The summary is for the member who asked and is never posted. 409 when they
236
+ * reached their daily limit, 400 when the thread is too short or the AI is
237
+ * unavailable: map either with `communityAgentSummaryError`.
238
+ */
239
+ export function useCommunityAgentSummary(postId?: string) {
240
+ const { client, profileId, communityId } = useCommunityScope("useCommunityAgentSummary()");
241
+ return useMutation({
242
+ mutationFn: async () => {
243
+ const res = await client.post(`/public/community-agent/posts/${postId}/summary`, { profileId, communityId });
244
+ return res.data as CommunityAgentSummary;
245
+ },
246
+ });
247
+ }
@@ -0,0 +1,150 @@
1
+ import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
2
+ import { useCommunityScope } from "../../contexts/CommunityContext";
3
+ import { useViewerAccountId } from "./useCommunities";
4
+ import { communityRequestStatus } from "./useCommunityClassroom";
5
+ import type { MyMembershipCall } from "./useMembershipCalls";
6
+
7
+ /**
8
+ * A community's calendar (community parity §6.5), from
9
+ * `GET /public/communities/:slug/calendar`: the calls this member can attend
10
+ * in the community and the events the creator promoted into it, on one
11
+ * time-ordered list. Members and the team only (401 signed out, 403 to a
12
+ * non-member, 404 for an unknown community). A window is at most 186 days;
13
+ * without one the server reads now to 62 days ahead.
14
+ *
15
+ * Going and not going on a call is
16
+ * `PUT /public/communities/:slug/calendar/calls/:callId/rsvp`, which answers
17
+ * the call's new counts. 409 once the call is cancelled or over.
18
+ */
19
+
20
+ export type CommunityRsvpResponse = "going" | "not_going";
21
+
22
+ export interface CommunityCallRsvp {
23
+ going: number;
24
+ notGoing: number;
25
+ mine: CommunityRsvpResponse | null;
26
+ }
27
+
28
+ /**
29
+ * A call on the community calendar: exactly the member's own call read (so the
30
+ * same details dialog renders it), plus the member's answer and a calendar file.
31
+ * `isOpen` is decided by the server from the same window the join enforces.
32
+ */
33
+ export interface CommunityCalendarCall extends MyMembershipCall {
34
+ kind: "call";
35
+ rsvp: CommunityCallRsvp;
36
+ icsUrl: string;
37
+ }
38
+
39
+ export interface CommunityCalendarEventAddress {
40
+ name?: string;
41
+ street?: string;
42
+ city?: string;
43
+ state?: string;
44
+ country?: string;
45
+ zipCode?: string;
46
+ }
47
+
48
+ export interface CommunityCalendarEvent {
49
+ kind: "event";
50
+ id: string;
51
+ title: string;
52
+ description: string | null;
53
+ slug: string;
54
+ startsAt: string;
55
+ endsAt: string | null;
56
+ timezone: string | null;
57
+ type: string;
58
+ address: CommunityCalendarEventAddress | null;
59
+ cancelled: boolean;
60
+ icsUrl: string;
61
+ }
62
+
63
+ export type CommunityCalendarItem = CommunityCalendarCall | CommunityCalendarEvent;
64
+
65
+ /** The longest window the server reads in one request. */
66
+ export const COMMUNITY_CALENDAR_MAX_RANGE_DAYS = 186;
67
+
68
+ export interface CommunityCalendarRange {
69
+ from?: Date | string;
70
+ to?: Date | string;
71
+ }
72
+
73
+ const iso = (value: Date | string | undefined) =>
74
+ value === undefined ? undefined : typeof value === "string" ? value : value.toISOString();
75
+
76
+ export const communityCalendarKeys = {
77
+ /** Every window of this viewer's calendar in this community, for cache updates. */
78
+ all: (profileId: string | undefined, slug: string | undefined, accountId: string | null) =>
79
+ ["public-community-calendar", profileId, slug, accountId] as const,
80
+ window: (
81
+ profileId: string | undefined,
82
+ slug: string | undefined,
83
+ accountId: string | null,
84
+ from: string | undefined,
85
+ to: string | undefined,
86
+ ) => ["public-community-calendar", profileId, slug, accountId, from ?? null, to ?? null] as const,
87
+ };
88
+
89
+ const retryable = (count: number, error: unknown) => {
90
+ const status = communityRequestStatus(error);
91
+ return (status === null || status >= 500) && count < 2;
92
+ };
93
+
94
+ /** The calendar's items in one window, time-ordered. */
95
+ export function useCommunityCalendarItems(range: CommunityCalendarRange = {}, opts: { enabled?: boolean } = {}) {
96
+ const { client, profileId, slug } = useCommunityScope("useCommunityCalendarItems()");
97
+ const accountId = useViewerAccountId();
98
+ const from = iso(range.from);
99
+ const to = iso(range.to);
100
+ return useQuery<CommunityCalendarItem[]>({
101
+ queryKey: communityCalendarKeys.window(profileId, slug, accountId, from, to),
102
+ queryFn: async () => {
103
+ const params: Record<string, string> = { profileId: profileId! };
104
+ if (from) params.from = from;
105
+ if (to) params.to = to;
106
+ return (await client.get(`/public/communities/${encodeURIComponent(slug!)}/calendar`, { params })).data;
107
+ },
108
+ enabled: opts.enabled !== false && !!accountId && !!slug && !!profileId && !!client,
109
+ retry: retryable,
110
+ });
111
+ }
112
+
113
+ /** Put a call's new counts into every cached window that lists it. */
114
+ export function setCalendarCallRsvp(
115
+ queryClient: QueryClient,
116
+ key: readonly unknown[],
117
+ callId: string,
118
+ rsvp: CommunityCallRsvp,
119
+ ) {
120
+ queryClient.setQueriesData<CommunityCalendarItem[]>({ queryKey: key }, (old) =>
121
+ Array.isArray(old)
122
+ ? old.map((item) => (item.kind === "call" && item.id === callId ? { ...item, rsvp } : item))
123
+ : old,
124
+ );
125
+ }
126
+
127
+ export interface CommunityRsvpInput {
128
+ callId: string;
129
+ /** Null clears the member's answer. */
130
+ response: CommunityRsvpResponse | null;
131
+ }
132
+
133
+ /** Answer going, not going, or clear the answer. The new counts land in every cached window. */
134
+ export function useCommunityCallRsvp() {
135
+ const { client, profileId, slug } = useCommunityScope("useCommunityCallRsvp()");
136
+ const accountId = useViewerAccountId();
137
+ const queryClient = useQueryClient();
138
+ return useMutation({
139
+ mutationFn: async (input: CommunityRsvpInput) =>
140
+ (
141
+ await client.put(
142
+ `/public/communities/${encodeURIComponent(slug!)}/calendar/calls/${encodeURIComponent(input.callId)}/rsvp`,
143
+ { profileId, response: input.response },
144
+ )
145
+ ).data as CommunityCallRsvp,
146
+ onSuccess: (rsvp, input) => {
147
+ setCalendarCallRsvp(queryClient, communityCalendarKeys.all(profileId, slug, accountId), input.callId, rsvp);
148
+ },
149
+ });
150
+ }
@@ -0,0 +1,75 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { useCommunityScope } from "../../contexts/CommunityContext";
3
+ import { useViewerAccountId } from "./useCommunities";
4
+
5
+ /**
6
+ * A community's classroom (community parity §6.3): the courses its tiers
7
+ * include, from `GET /public/communities/:slug/classroom`. Members and the
8
+ * creator's team only: the server answers 401 signed out, 403 to a non-member
9
+ * and 404 for an unknown community.
10
+ */
11
+
12
+ export interface CommunityClassroomTier {
13
+ id: string;
14
+ name: string;
15
+ }
16
+
17
+ /** The viewer's progress on a course they hold. */
18
+ export interface CommunityClassroomProgress {
19
+ completed: number;
20
+ total: number;
21
+ completedAt: string | null;
22
+ currentLessonId: string | null;
23
+ }
24
+
25
+ /** One course card. */
26
+ export interface CommunityClassroomCourse {
27
+ courseId: string;
28
+ title: string;
29
+ description: string;
30
+ slug: string;
31
+ coverUrl: string | null;
32
+ /** The viewer holds the course, so it opens. */
33
+ open: boolean;
34
+ /** Only for a course the viewer holds. */
35
+ progress: CommunityClassroomProgress | null;
36
+ /** The level that opens it, when the viewer's own tier grants it at a level they have not reached. */
37
+ unlockLevel: number | null;
38
+ /** Other tiers of this community that include it, when the viewer's tier does not. */
39
+ unlockTiers: CommunityClassroomTier[];
40
+ /**
41
+ * The viewer's live course access id, which addresses the course player
42
+ * (`course-access/$accessId`). Null for a course they do not hold.
43
+ */
44
+ accessId: string | null;
45
+ }
46
+
47
+ export const communityClassroomKeys = {
48
+ list: (profileId: string | undefined, slug: string | undefined, accountId: string | null) =>
49
+ ["public-community-classroom", profileId, slug, accountId] as const,
50
+ };
51
+
52
+ /** The status of a failed request, or null. */
53
+ export function communityRequestStatus(error: unknown): number | null {
54
+ const status = (error as { response?: { status?: unknown } } | null)?.response?.status;
55
+ return typeof status === "number" ? status : null;
56
+ }
57
+
58
+ /** 401, 403 and 404 are answers, not outages: asking again changes nothing. */
59
+ const retryable = (count: number, error: unknown) => {
60
+ const status = communityRequestStatus(error);
61
+ return (status === null || status >= 500) && count < 2;
62
+ };
63
+
64
+ /** The classroom's course cards, for the signed-in viewer. */
65
+ export function useCommunityClassroomCourses(opts: { enabled?: boolean } = {}) {
66
+ const { client, profileId, slug } = useCommunityScope("useCommunityClassroomCourses()");
67
+ const accountId = useViewerAccountId();
68
+ return useQuery<CommunityClassroomCourse[]>({
69
+ queryKey: communityClassroomKeys.list(profileId, slug, accountId),
70
+ queryFn: async () =>
71
+ (await client.get(`/public/communities/${encodeURIComponent(slug!)}/classroom`, { params: { profileId } })).data,
72
+ enabled: opts.enabled !== false && !!accountId && !!slug && !!profileId && !!client,
73
+ retry: retryable,
74
+ });
75
+ }
@@ -0,0 +1,108 @@
1
+ import { keepPreviousData, useQuery } from "@tanstack/react-query";
2
+ import { useCommunityScope } from "../../contexts/CommunityContext";
3
+
4
+ /**
5
+ * The community member directory and member map (community parity §6.6), on
6
+ * the member session. The server lists current members and the team only, and
7
+ * leaves out anyone who opted out, lapsed or was banned. It never returns an
8
+ * email. A non-member gets 403.
9
+ */
10
+
11
+ export type CommunityDirectorySort = "active" | "points" | "newest" | "name";
12
+ export type CommunityDirectoryRole = "member" | "moderator";
13
+
14
+ /** One member card (GET /public/community/members). */
15
+ export interface CommunityDirectoryMember {
16
+ accountId: string;
17
+ displayName: string | null;
18
+ avatarUrl: string | null;
19
+ bio: string | null;
20
+ level: number;
21
+ points: number;
22
+ role: string;
23
+ /** Free text the member typed; display only. */
24
+ city: string | null;
25
+ /** ISO 3166-1 alpha-2. */
26
+ countryCode: string | null;
27
+ lastActiveAt: string | null;
28
+ joinedAt: string | null;
29
+ firstName: string | null;
30
+ lastName: string | null;
31
+ }
32
+
33
+ export interface CommunityDirectoryPage {
34
+ data: CommunityDirectoryMember[];
35
+ total: number;
36
+ page: number;
37
+ pageSize: number;
38
+ hasNextPage: boolean;
39
+ }
40
+
41
+ /** One country on the member map: how many opted-in members are there. */
42
+ export interface CommunityMemberMapCountry {
43
+ countryCode: string;
44
+ members: number;
45
+ }
46
+
47
+ export interface CommunityDirectoryQuery {
48
+ q?: string;
49
+ role?: CommunityDirectoryRole | null;
50
+ minLevel?: number | null;
51
+ sort?: CommunityDirectorySort;
52
+ page?: number;
53
+ limit?: number;
54
+ }
55
+
56
+ export const COMMUNITY_DIRECTORY_PAGE_SIZE = 24;
57
+ const MAX_LIMIT = 100;
58
+
59
+ /**
60
+ * The directory's query string, cleaned to what the server accepts: a blank
61
+ * search is left out rather than sent empty, a level outside 1 to 9 is
62
+ * dropped, the page is at least 1 and the limit 1 to 100. Pure, so the same
63
+ * filters always make the same cache key.
64
+ */
65
+ export function buildDirectoryParams(query: CommunityDirectoryQuery = {}): Record<string, string | number> {
66
+ const params: Record<string, string | number> = {};
67
+ const q = query.q?.trim().slice(0, 80);
68
+ if (q) params.q = q;
69
+ if (query.role === "member" || query.role === "moderator") params.role = query.role;
70
+ const level = Number(query.minLevel);
71
+ if (Number.isInteger(level) && level >= 1 && level <= 9) params.minLevel = level;
72
+ params.sort = query.sort ?? "active";
73
+ const page = Math.floor(Number(query.page));
74
+ params.page = Number.isFinite(page) && page >= 1 ? page : 1;
75
+ const limit = Math.floor(Number(query.limit ?? COMMUNITY_DIRECTORY_PAGE_SIZE));
76
+ params.limit = Number.isFinite(limit) ? Math.min(MAX_LIMIT, Math.max(1, limit)) : COMMUNITY_DIRECTORY_PAGE_SIZE;
77
+ return params;
78
+ }
79
+
80
+ export const communityMembersKeys = {
81
+ directory: (communityId: string | undefined, params: Record<string, string | number>) =>
82
+ ["public-community-directory", communityId, params] as const,
83
+ map: (communityId: string | undefined) => ["public-community-member-map", communityId] as const,
84
+ };
85
+
86
+ /** One page of the member directory. The previous page stays on screen while the next loads. */
87
+ export function useCommunityDirectory(query: CommunityDirectoryQuery = {}, opts: { enabled?: boolean } = {}) {
88
+ const { client, profileId, communityId } = useCommunityScope("useCommunityDirectory()");
89
+ const params = buildDirectoryParams(query);
90
+ return useQuery<CommunityDirectoryPage>({
91
+ queryKey: communityMembersKeys.directory(communityId, params),
92
+ queryFn: async () =>
93
+ (await client.get("/public/community/members", { params: { profileId, communityId, ...params } })).data,
94
+ placeholderData: keepPreviousData,
95
+ enabled: opts.enabled !== false && !!profileId && !!communityId && !!client,
96
+ });
97
+ }
98
+
99
+ /** Opted-in members per country, for the member globe. */
100
+ export function useCommunityMemberMap(opts: { enabled?: boolean } = {}) {
101
+ const { client, profileId, communityId } = useCommunityScope("useCommunityMemberMap()");
102
+ return useQuery<CommunityMemberMapCountry[]>({
103
+ queryKey: communityMembersKeys.map(communityId),
104
+ queryFn: async () =>
105
+ (await client.get("/public/community/members/map", { params: { profileId, communityId } })).data,
106
+ enabled: opts.enabled !== false && !!profileId && !!communityId && !!client,
107
+ });
108
+ }