@tribe-nest/forge 3.59.0 → 3.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/_tests/testCommunity.tsx +28 -0
- package/src/contexts/CartContext.tsx +34 -1
- package/src/contexts/CommunityContext.tsx +192 -0
- package/src/contexts/PublicAuthContext.tsx +15 -3
- package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
- package/src/data/queries/useAccount.ts +33 -2
- package/src/data/queries/useAnalytics.ts +6 -6
- package/src/data/queries/useCallChat.ts +2 -2
- package/src/data/queries/useChat.ts +52 -40
- package/src/data/queries/useCheckouts.ts +2 -1
- package/src/data/queries/useCoachingAvailability.ts +10 -1
- package/src/data/queries/useCommunities.ts +345 -0
- package/src/data/queries/useCommunity.ts +450 -102
- package/src/data/queries/useCommunityAgent.ts +247 -0
- package/src/data/queries/useCommunityCalendar.ts +150 -0
- package/src/data/queries/useCommunityClassroom.ts +75 -0
- package/src/data/queries/useCommunityMembers.ts +108 -0
- package/src/data/queries/useCommunityModeration.ts +327 -0
- package/src/data/queries/useCommunityWiki.ts +137 -0
- package/src/data/queries/useCourses.ts +2 -0
- package/src/data/queries/useEmailSignIn.ts +79 -0
- package/src/data/queries/useEvents.ts +6 -1
- package/src/data/queries/useForms.ts +5 -1
- package/src/data/queries/useLeadMagnet.ts +6 -2
- package/src/data/queries/useMembership.ts +32 -7
- package/src/data/queries/useMembershipCalls.ts +116 -7
- package/src/data/queries/useMyBookings.ts +24 -1
- package/src/data/queries/useMyTickets.ts +24 -1
- package/src/data/queries/useNotifications.ts +6 -2
- package/src/data/queries/useOrders.ts +2 -1
- package/src/data/queries/usePWA.ts +111 -35
- package/src/data/queries/usePostCollections.ts +28 -16
- package/src/data/queries/usePosts.ts +16 -14
- package/src/data/queries/useSiteApp.ts +130 -0
- package/src/data/queries/useSubscriptions.ts +28 -9
- package/src/i18n/de.json +891 -2
- package/src/i18n/en.json +891 -2
- package/src/index.ts +36 -7
- package/src/runtime/pages.spec.ts +36 -0
- package/src/runtime/pages.ts +52 -27
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
- package/src/types/models.ts +11 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
- package/src/ui/analytics/PageMetaPixel.tsx +23 -8
- package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
- package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
- package/src/ui/analytics/useMetaEvents.ts +78 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
- package/src/ui/format/membershipGate.ts +19 -15
- package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
- package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
- package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
- package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
- package/src/ui/headless/_tests/communityHarness.tsx +212 -0
- package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
- package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
- package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
- package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
- package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
- package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
- package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
- package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
- package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
- package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
- package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
- package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
- package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
- package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
- package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
- package/src/ui/headless/auth/useSignupForm.ts +30 -3
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
- package/src/ui/headless/chat/useChatRoom.ts +35 -1
- package/src/ui/headless/communities/CommunityGate.tsx +67 -0
- package/src/ui/headless/communities/index.ts +96 -0
- package/src/ui/headless/communities/paths.ts +36 -0
- package/src/ui/headless/communities/useCommunity.ts +140 -0
- package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
- package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
- package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
- package/src/ui/headless/communities/useCommunityHome.ts +91 -0
- package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
- package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
- package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
- package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
- package/src/ui/headless/communities/useMyMemberships.ts +59 -0
- package/src/ui/headless/community/communityPoll.ts +297 -0
- package/src/ui/headless/community/countries.ts +123 -0
- package/src/ui/headless/community/index.ts +81 -1
- package/src/ui/headless/community/linkPreview.ts +71 -0
- package/src/ui/headless/community/richLexical.ts +27 -2
- package/src/ui/headless/community/useCommunityAgent.ts +285 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
- package/src/ui/headless/community/useCommunityMembers.ts +175 -0
- package/src/ui/headless/community/useCommunityModeration.ts +300 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
- package/src/ui/headless/community/useCommunityThread.ts +16 -3
- package/src/ui/headless/community/useMentionComposer.ts +17 -9
- package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
- package/src/ui/headless/downloads/index.ts +8 -0
- package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
- package/src/ui/headless/index.ts +21 -4
- package/src/ui/headless/memberHome/index.ts +11 -1
- package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
- package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
- package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
- package/src/ui/headless/portal/index.ts +57 -0
- package/src/ui/headless/portal/usePortal.ts +274 -0
- package/src/ui/headless/portal/usePortalFiles.ts +68 -0
- package/src/ui/headless/portal/usePortalMessages.ts +189 -0
- package/src/ui/headless/portal/usePortalPreview.ts +142 -0
- package/src/ui/headless/posts/upsell.ts +54 -8
- package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
- package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
- package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
- package/src/ui/headless/posts/usePostItem.ts +6 -5
- package/src/ui/headless/siteApp/appBar.ts +102 -0
- package/src/ui/headless/siteApp/appHome.ts +344 -0
- package/src/ui/headless/siteApp/appPrompts.ts +229 -0
- package/src/ui/headless/siteApp/index.ts +58 -0
- package/src/ui/headless/siteApp/useAppHome.ts +210 -0
- package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
- package/src/ui/headless/work/index.ts +2 -0
- package/src/ui/headless/work/useWorkPortal.ts +37 -26
- package/src/ui/index.ts +28 -12
- package/src/ui/styled/AccountDashboard.tsx +754 -602
- package/src/ui/styled/AiAgentWidget.tsx +13 -1
- package/src/ui/styled/ChatRoom.tsx +126 -16
- package/src/ui/styled/Checkout.tsx +8 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingDetail.tsx +2 -0
- package/src/ui/styled/CourseConfirmation.tsx +5 -0
- package/src/ui/styled/CourseDetail.tsx +2 -0
- package/src/ui/styled/DownloadPage.tsx +263 -0
- package/src/ui/styled/EventConfirmation.tsx +10 -0
- package/src/ui/styled/EventDetail.tsx +10 -0
- package/src/ui/styled/FilmLibrary.tsx +22 -9
- package/src/ui/styled/MembershipCheckout.tsx +37 -17
- package/src/ui/styled/MembershipTierCallout.tsx +20 -1
- package/src/ui/styled/MembershipTiers.tsx +49 -11
- package/src/ui/styled/MusicLinkPage.tsx +5 -5
- package/src/ui/styled/Paywall.tsx +1 -1
- package/src/ui/styled/ProductDetail.tsx +12 -0
- package/src/ui/styled/PwaRegistration.tsx +5 -1
- package/src/ui/styled/SignupForm.tsx +18 -3
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
- package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
- package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
- package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
- package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
- package/src/ui/styled/app/AppBar.tsx +298 -0
- package/src/ui/styled/app/AppHome.tsx +1139 -0
- package/src/ui/styled/app/AppHomeMark.tsx +66 -0
- package/src/ui/styled/app/AppPrompts.tsx +464 -0
- package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
- package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
- package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
- package/src/ui/styled/app/index.ts +7 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
- package/src/ui/styled/chat/MessageBubble.tsx +43 -6
- package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
- package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
- package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
- package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
- package/src/ui/styled/communities/CommunityHome.tsx +298 -0
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
- package/src/ui/styled/communities/CommunityPending.tsx +49 -0
- package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
- package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
- package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
- package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
- package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
- package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
- package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
- package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
- package/src/ui/styled/communities/index.ts +19 -0
- package/src/ui/styled/communities/shared.tsx +177 -0
- package/src/ui/styled/community/CommunityAgent.tsx +286 -0
- package/src/ui/styled/community/CommunityComposer.tsx +266 -15
- package/src/ui/styled/community/CommunityFeed.tsx +50 -1
- package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
- package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
- package/src/ui/styled/community/CommunityMembers.tsx +387 -0
- package/src/ui/styled/community/CommunityModeration.tsx +587 -0
- package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
- package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
- package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
- package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
- package/src/ui/styled/community/CommunityRichText.tsx +177 -32
- package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
- package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
- package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
- package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
- package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
- package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
- package/src/ui/styled/community/index.ts +37 -2
- package/src/ui/styled/forge-utilities.css +253 -0
- package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
- package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
- package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
- package/src/ui/styled/members/MemberPostCard.tsx +9 -5
- package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
- package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
- package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
- package/src/ui/styled/members/callTranscriptText.ts +67 -0
- package/src/ui/styled/members/index.ts +8 -0
- package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
- package/src/ui/styled/portal/PortalActivity.tsx +167 -0
- package/src/ui/styled/portal/PortalBilling.tsx +197 -0
- package/src/ui/styled/portal/PortalFiles.tsx +279 -0
- package/src/ui/styled/portal/PortalHome.tsx +401 -0
- package/src/ui/styled/portal/PortalMessages.tsx +300 -0
- package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
- package/src/ui/styled/portal/PortalProjects.tsx +407 -0
- package/src/ui/styled/portal/PortalShell.tsx +397 -0
- package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
- package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
- package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
- package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
- package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
- package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
- package/src/ui/styled/portal/index.ts +13 -0
- package/src/ui/styled/portal/portalNav.ts +260 -0
- package/src/ui/styled/portal/shared.tsx +206 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
- package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
- package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
- package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
- package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
- package/src/ui/styled/work/WorkReportBody.tsx +223 -248
- package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
- package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
- package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
- package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
- package/src/ui/styled/work/index.ts +1 -1
- package/src/utils/_tests/metaEvents.spec.ts +264 -0
- package/src/utils/_tests/safeRedirect.spec.ts +2 -2
- package/src/utils/landing.ts +19 -6
- package/src/utils/metaEvents.ts +302 -0
- package/src/ui/headless/membership/MembershipGate.tsx +0 -44
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
|
@@ -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
|
+
}
|