@tribe-nest/forge 3.59.0 → 3.60.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 +1 -1
- package/src/_tests/testCommunity.tsx +28 -0
- package/src/contexts/CommunityContext.tsx +192 -0
- package/src/data/queries/useChat.ts +42 -39
- package/src/data/queries/useCommunities.ts +343 -0
- package/src/data/queries/useCommunity.ts +93 -89
- package/src/data/queries/useMembership.ts +32 -7
- package/src/data/queries/useMembershipCalls.ts +116 -7
- package/src/data/queries/usePostCollections.ts +28 -16
- package/src/data/queries/usePosts.ts +16 -14
- package/src/data/queries/useSubscriptions.ts +22 -8
- package/src/i18n/de.json +145 -1
- package/src/i18n/en.json +145 -1
- package/src/index.ts +19 -7
- package/src/runtime/pages.spec.ts +31 -0
- package/src/runtime/pages.ts +21 -22
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
- package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
- package/src/ui/format/membershipGate.ts +19 -15
- package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
- package/src/ui/headless/_tests/communityHarness.tsx +205 -0
- package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
- package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +382 -0
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +151 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +286 -0
- package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
- package/src/ui/headless/auth/useSignupForm.ts +10 -2
- package/src/ui/headless/communities/CommunityGate.tsx +67 -0
- package/src/ui/headless/communities/index.ts +55 -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/useCommunityHome.ts +88 -0
- package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +444 -0
- package/src/ui/headless/communities/useCommunityMembership.ts +136 -0
- package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
- package/src/ui/headless/communities/useMyMemberships.ts +59 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
- package/src/ui/headless/index.ts +8 -4
- package/src/ui/headless/memberHome/index.ts +1 -1
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +44 -42
- 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/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/index.ts +17 -12
- package/src/ui/styled/AccountDashboard.tsx +48 -256
- 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/Paywall.tsx +1 -1
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +1 -1
- 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/CourseCallsSection.spec.tsx +7 -1
- 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/communities/CommunitiesList.tsx +88 -0
- package/src/ui/styled/communities/CommunityAbout.tsx +140 -0
- package/src/ui/styled/communities/CommunityHome.tsx +175 -0
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +444 -0
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +133 -0
- package/src/ui/styled/communities/CommunityPending.tsx +49 -0
- package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +89 -0
- package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
- package/src/ui/styled/communities/index.ts +10 -0
- package/src/ui/styled/communities/shared.tsx +149 -0
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
- package/src/ui/styled/forge-utilities.css +19 -0
- package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +142 -10
- package/src/ui/styled/members/MemberHomeLayout.tsx +35 -17
- package/src/ui/styled/members/MemberPostCard.tsx +9 -5
- 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/utils/_tests/safeRedirect.spec.ts +2 -2
- 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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
|
2
|
-
import {
|
|
2
|
+
import { useCommunityScope } from "../../contexts/CommunityContext";
|
|
3
3
|
import { useChatSocket } from "./useChat";
|
|
4
4
|
|
|
5
5
|
// ── Types (mirror apps/backend/src/routes/public/community + models) ─────────
|
|
@@ -17,7 +17,7 @@ export interface CommunitySpace {
|
|
|
17
17
|
order: number;
|
|
18
18
|
archivedAt: string | null;
|
|
19
19
|
createdAt: string;
|
|
20
|
-
/** Gating tiers
|
|
20
|
+
/** Gating tiers: empty array = open to every member. */
|
|
21
21
|
tiers: { id: string; name: string }[];
|
|
22
22
|
/** Only present on the member list query. */
|
|
23
23
|
postCount?: number | null;
|
|
@@ -234,35 +234,37 @@ export function communityMemberName(
|
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
// Query keys are exported so the realtime layer (and creators) can patch the
|
|
237
|
-
// same caches the data hooks populate
|
|
237
|
+
// same caches the data hooks populate: mirrors chatKeys in useChat.ts.
|
|
238
238
|
export const communityKeys = {
|
|
239
|
-
|
|
239
|
+
// Scoped by COMMUNITY, not profile: a profile runs several, and one cache
|
|
240
|
+
// entry per profile would show community A's spaces inside community B.
|
|
241
|
+
spaces: (communityId?: string) => ["public-community-spaces", communityId] as const,
|
|
240
242
|
spaceMembers: (spaceId?: string) => ["public-community-space-members", spaceId] as const,
|
|
241
243
|
posts: (spaceId?: string) => ["public-community-posts", spaceId] as const,
|
|
242
244
|
post: (postId?: string) => ["public-community-post", postId] as const,
|
|
243
245
|
replies: (postId?: string) => ["public-community-replies", postId] as const,
|
|
244
|
-
myProfile: (
|
|
245
|
-
member: (
|
|
246
|
-
notifications: (
|
|
247
|
-
["public-community-notifications",
|
|
248
|
-
unread: (
|
|
249
|
-
leaderboard: (
|
|
250
|
-
search: (
|
|
251
|
-
["public-community-search",
|
|
246
|
+
myProfile: (communityId?: string) => ["public-community-my-profile", communityId] as const,
|
|
247
|
+
member: (communityId?: string, accountId?: string) => ["public-community-member", communityId, accountId] as const,
|
|
248
|
+
notifications: (communityId?: string, unreadOnly?: boolean) =>
|
|
249
|
+
["public-community-notifications", communityId, unreadOnly] as const,
|
|
250
|
+
unread: (communityId?: string) => ["public-community-unread", communityId] as const,
|
|
251
|
+
leaderboard: (communityId?: string, period?: string) => ["public-community-leaderboard", communityId, period] as const,
|
|
252
|
+
search: (communityId?: string, q?: string, spaceId?: string) =>
|
|
253
|
+
["public-community-search", communityId, q, spaceId ?? null] as const,
|
|
252
254
|
};
|
|
253
255
|
|
|
254
256
|
// ── Data hooks ───────────────────────────────────────────────────────────────
|
|
255
257
|
|
|
256
258
|
/** Spaces the current member can access (GET /public/community/spaces). */
|
|
257
259
|
export function useCommunitySpaces() {
|
|
258
|
-
const { client, profileId } =
|
|
260
|
+
const { client, profileId, communityId } = useCommunityScope("useCommunitySpaces()");
|
|
259
261
|
return useQuery<CommunitySpace[]>({
|
|
260
|
-
queryKey: communityKeys.spaces(
|
|
262
|
+
queryKey: communityKeys.spaces(communityId),
|
|
261
263
|
queryFn: async () => {
|
|
262
|
-
const res = await client.get("/public/community/spaces", { params: { profileId } });
|
|
264
|
+
const res = await client.get("/public/community/spaces", { params: { profileId, communityId } });
|
|
263
265
|
return res.data;
|
|
264
266
|
},
|
|
265
|
-
enabled: !!profileId && !!client,
|
|
267
|
+
enabled: !!profileId && !!communityId && !!client,
|
|
266
268
|
});
|
|
267
269
|
}
|
|
268
270
|
|
|
@@ -274,30 +276,30 @@ export interface SpaceMembershipResult {
|
|
|
274
276
|
/** Patch one space's isJoined/memberCount inside the spaces list cache. */
|
|
275
277
|
export function setSpaceMembership(
|
|
276
278
|
queryClient: QueryClient,
|
|
277
|
-
|
|
279
|
+
communityId: string | undefined,
|
|
278
280
|
spaceId: string,
|
|
279
281
|
membership: SpaceMembershipResult,
|
|
280
282
|
) {
|
|
281
|
-
queryClient.setQueryData(communityKeys.spaces(
|
|
283
|
+
queryClient.setQueryData(communityKeys.spaces(communityId), (old: CommunitySpace[] | undefined) =>
|
|
282
284
|
old?.map((s) => (s.id === spaceId ? { ...s, ...membership } : s)),
|
|
283
285
|
);
|
|
284
286
|
}
|
|
285
287
|
|
|
286
288
|
/**
|
|
287
289
|
* Join a space (POST /public/community/spaces/:id/join). Tiers still govern
|
|
288
|
-
* who can access a space
|
|
290
|
+
* who can access a space: joining opts into new-post notifications/digests
|
|
289
291
|
* and the roster. Interacting (post/reply/react) auto-joins server-side.
|
|
290
292
|
*/
|
|
291
293
|
export function useJoinCommunitySpace() {
|
|
292
|
-
const { client, profileId } =
|
|
294
|
+
const { client, profileId, communityId } = useCommunityScope("useJoinCommunitySpace()");
|
|
293
295
|
const queryClient = useQueryClient();
|
|
294
296
|
return useMutation({
|
|
295
297
|
mutationFn: async (spaceId: string) => {
|
|
296
|
-
const res = await client.post(`/public/community/spaces/${spaceId}/join`, { profileId });
|
|
298
|
+
const res = await client.post(`/public/community/spaces/${spaceId}/join`, { profileId, communityId });
|
|
297
299
|
return { ...(res.data as SpaceMembershipResult), spaceId };
|
|
298
300
|
},
|
|
299
301
|
onSuccess: ({ spaceId, ...membership }) => {
|
|
300
|
-
setSpaceMembership(queryClient,
|
|
302
|
+
setSpaceMembership(queryClient, communityId, spaceId, membership);
|
|
301
303
|
queryClient.invalidateQueries({ queryKey: communityKeys.spaceMembers(spaceId) });
|
|
302
304
|
},
|
|
303
305
|
});
|
|
@@ -305,15 +307,15 @@ export function useJoinCommunitySpace() {
|
|
|
305
307
|
|
|
306
308
|
/** Leave a space (POST /public/community/spaces/:id/leave). Always explicit. */
|
|
307
309
|
export function useLeaveCommunitySpace() {
|
|
308
|
-
const { client, profileId } =
|
|
310
|
+
const { client, profileId, communityId } = useCommunityScope("useLeaveCommunitySpace()");
|
|
309
311
|
const queryClient = useQueryClient();
|
|
310
312
|
return useMutation({
|
|
311
313
|
mutationFn: async (spaceId: string) => {
|
|
312
|
-
const res = await client.post(`/public/community/spaces/${spaceId}/leave`, { profileId });
|
|
314
|
+
const res = await client.post(`/public/community/spaces/${spaceId}/leave`, { profileId, communityId });
|
|
313
315
|
return { ...(res.data as SpaceMembershipResult), spaceId };
|
|
314
316
|
},
|
|
315
317
|
onSuccess: ({ spaceId, ...membership }) => {
|
|
316
|
-
setSpaceMembership(queryClient,
|
|
318
|
+
setSpaceMembership(queryClient, communityId, spaceId, membership);
|
|
317
319
|
queryClient.invalidateQueries({ queryKey: communityKeys.spaceMembers(spaceId) });
|
|
318
320
|
},
|
|
319
321
|
});
|
|
@@ -321,81 +323,81 @@ export function useLeaveCommunitySpace() {
|
|
|
321
323
|
|
|
322
324
|
/** Paginated joined-member roster for a space. */
|
|
323
325
|
export function useCommunitySpaceMembers(spaceId?: string, limit = 50) {
|
|
324
|
-
const { client, profileId } =
|
|
326
|
+
const { client, profileId, communityId } = useCommunityScope("useCommunitySpaceMembers()");
|
|
325
327
|
return useInfiniteQuery<CommunitySpaceMembersPage>({
|
|
326
328
|
queryKey: communityKeys.spaceMembers(spaceId),
|
|
327
329
|
queryFn: async ({ pageParam = 1 }) => {
|
|
328
330
|
const res = await client.get(`/public/community/spaces/${spaceId}/members`, {
|
|
329
|
-
params: { profileId, page: pageParam, limit },
|
|
331
|
+
params: { profileId, communityId, page: pageParam, limit },
|
|
330
332
|
});
|
|
331
333
|
return res.data;
|
|
332
334
|
},
|
|
333
335
|
initialPageParam: 1,
|
|
334
336
|
getNextPageParam: (lastPage) => (lastPage.hasNextPage ? lastPage.page + 1 : undefined),
|
|
335
|
-
enabled: !!spaceId && !!client && !!profileId,
|
|
337
|
+
enabled: !!spaceId && !!client && !!profileId && !!communityId,
|
|
336
338
|
});
|
|
337
339
|
}
|
|
338
340
|
|
|
339
341
|
/** Paginated posts for a space, pinned first then most recent activity. */
|
|
340
342
|
export function useCommunitySpacePosts(spaceId?: string, limit = 20) {
|
|
341
|
-
const { client, profileId } =
|
|
343
|
+
const { client, profileId, communityId } = useCommunityScope("useCommunitySpacePosts()");
|
|
342
344
|
return useInfiniteQuery<CommunityPostsPage>({
|
|
343
345
|
queryKey: communityKeys.posts(spaceId),
|
|
344
346
|
queryFn: async ({ pageParam = 1 }) => {
|
|
345
347
|
const res = await client.get(`/public/community/spaces/${spaceId}/posts`, {
|
|
346
|
-
params: { profileId, page: pageParam, limit },
|
|
348
|
+
params: { profileId, communityId, page: pageParam, limit },
|
|
347
349
|
});
|
|
348
350
|
return res.data;
|
|
349
351
|
},
|
|
350
352
|
initialPageParam: 1,
|
|
351
353
|
getNextPageParam: (lastPage) => (lastPage.hasNextPage ? lastPage.page + 1 : undefined),
|
|
352
|
-
enabled: !!spaceId && !!client && !!profileId,
|
|
354
|
+
enabled: !!spaceId && !!client && !!profileId && !!communityId,
|
|
353
355
|
});
|
|
354
356
|
}
|
|
355
357
|
|
|
356
358
|
/**
|
|
357
359
|
* A single post (GET /public/community/posts/:id). The endpoint also returns a
|
|
358
|
-
* replies page
|
|
360
|
+
* replies page: we request the minimum (limit=1) and let the dedicated
|
|
359
361
|
* replies hook own pagination, so both caches stay simple.
|
|
360
362
|
*/
|
|
361
363
|
export function useCommunityPost(postId?: string) {
|
|
362
|
-
const { client, profileId } =
|
|
364
|
+
const { client, profileId, communityId } = useCommunityScope("useCommunityPost()");
|
|
363
365
|
return useQuery<CommunityPost>({
|
|
364
366
|
queryKey: communityKeys.post(postId),
|
|
365
367
|
queryFn: async () => {
|
|
366
368
|
const res = await client.get(`/public/community/posts/${postId}`, {
|
|
367
|
-
params: { profileId, page: 1, limit: 1 },
|
|
369
|
+
params: { profileId, communityId, page: 1, limit: 1 },
|
|
368
370
|
});
|
|
369
371
|
return res.data.post;
|
|
370
372
|
},
|
|
371
|
-
enabled: !!postId && !!client && !!profileId,
|
|
373
|
+
enabled: !!postId && !!client && !!profileId && !!communityId,
|
|
372
374
|
});
|
|
373
375
|
}
|
|
374
376
|
|
|
375
377
|
/**
|
|
376
378
|
* Flat, chronological reply pages for a post (GET /public/community/posts/:id/replies).
|
|
377
|
-
* `parentReplyId` is included on every row
|
|
379
|
+
* `parentReplyId` is included on every row: assemble the tree with
|
|
378
380
|
* `buildReplyTree` (data nesting is unlimited; the 3-level display cap is a UI concern).
|
|
379
381
|
*/
|
|
380
382
|
export function useCommunityReplies(postId?: string, limit = 100) {
|
|
381
|
-
const { client, profileId } =
|
|
383
|
+
const { client, profileId, communityId } = useCommunityScope("useCommunityReplies()");
|
|
382
384
|
return useInfiniteQuery<CommunityRepliesPage>({
|
|
383
385
|
queryKey: communityKeys.replies(postId),
|
|
384
386
|
queryFn: async ({ pageParam = 1 }) => {
|
|
385
387
|
const res = await client.get(`/public/community/posts/${postId}/replies`, {
|
|
386
|
-
params: { profileId, page: pageParam, limit },
|
|
388
|
+
params: { profileId, communityId, page: pageParam, limit },
|
|
387
389
|
});
|
|
388
390
|
return res.data;
|
|
389
391
|
},
|
|
390
392
|
initialPageParam: 1,
|
|
391
393
|
getNextPageParam: (lastPage) => (lastPage.hasNextPage ? lastPage.page + 1 : undefined),
|
|
392
|
-
enabled: !!postId && !!client && !!profileId,
|
|
394
|
+
enabled: !!postId && !!client && !!profileId && !!communityId,
|
|
393
395
|
});
|
|
394
396
|
}
|
|
395
397
|
|
|
396
398
|
export interface CreateCommunityPostInput {
|
|
397
399
|
title?: string;
|
|
398
|
-
/** Lexical editor state
|
|
400
|
+
/** Lexical editor state: build one from plain text with `buildLexicalState`. */
|
|
399
401
|
body: unknown;
|
|
400
402
|
attachments?: CommunityAttachmentInput[];
|
|
401
403
|
mentionedAccountIds?: string[];
|
|
@@ -403,16 +405,16 @@ export interface CreateCommunityPostInput {
|
|
|
403
405
|
|
|
404
406
|
/** Create a post in a space (POST /public/community/spaces/:id/posts). */
|
|
405
407
|
export function useCreateCommunityPost(spaceId?: string) {
|
|
406
|
-
const { client, profileId } =
|
|
408
|
+
const { client, profileId, communityId } = useCommunityScope("useCreateCommunityPost()");
|
|
407
409
|
const queryClient = useQueryClient();
|
|
408
410
|
return useMutation({
|
|
409
411
|
mutationFn: async (input: CreateCommunityPostInput) => {
|
|
410
|
-
const res = await client.post(`/public/community/spaces/${spaceId}/posts`, { profileId, ...input });
|
|
412
|
+
const res = await client.post(`/public/community/spaces/${spaceId}/posts`, { profileId, communityId, ...input });
|
|
411
413
|
return res.data as CommunityPost;
|
|
412
414
|
},
|
|
413
415
|
onSuccess: (post) => {
|
|
414
416
|
prependPostToFeed(queryClient, spaceId, post);
|
|
415
|
-
queryClient.invalidateQueries({ queryKey: communityKeys.spaces(
|
|
417
|
+
queryClient.invalidateQueries({ queryKey: communityKeys.spaces(communityId) });
|
|
416
418
|
},
|
|
417
419
|
});
|
|
418
420
|
}
|
|
@@ -424,19 +426,19 @@ export interface CreateCommunityReplyInput {
|
|
|
424
426
|
mentionedAccountIds?: string[];
|
|
425
427
|
}
|
|
426
428
|
|
|
427
|
-
/** Reply to a post
|
|
429
|
+
/** Reply to a post: optionally nested under another reply. */
|
|
428
430
|
export function useCreateCommunityReply(postId?: string) {
|
|
429
|
-
const { client, profileId } =
|
|
431
|
+
const { client, profileId, communityId } = useCommunityScope("useCreateCommunityReply()");
|
|
430
432
|
const queryClient = useQueryClient();
|
|
431
433
|
return useMutation({
|
|
432
434
|
mutationFn: async (input: CreateCommunityReplyInput) => {
|
|
433
|
-
const res = await client.post(`/public/community/posts/${postId}/replies`, { profileId, ...input });
|
|
435
|
+
const res = await client.post(`/public/community/posts/${postId}/replies`, { profileId, communityId, ...input });
|
|
434
436
|
return res.data as CommunityReply;
|
|
435
437
|
},
|
|
436
438
|
onSuccess: (reply) => {
|
|
437
439
|
appendReplyToThread(queryClient, postId, reply);
|
|
438
440
|
// Replying implicitly joins the post's space server-side.
|
|
439
|
-
queryClient.invalidateQueries({ queryKey: communityKeys.spaces(
|
|
441
|
+
queryClient.invalidateQueries({ queryKey: communityKeys.spaces(communityId) });
|
|
440
442
|
},
|
|
441
443
|
});
|
|
442
444
|
}
|
|
@@ -452,7 +454,7 @@ export interface ToggleReactionResult {
|
|
|
452
454
|
|
|
453
455
|
/** Toggle an emoji reaction (POST /public/community/:entityType/:id/react). */
|
|
454
456
|
export function useToggleCommunityReaction() {
|
|
455
|
-
const { client, profileId } =
|
|
457
|
+
const { client, profileId, communityId } = useCommunityScope("useToggleCommunityReaction()");
|
|
456
458
|
const queryClient = useQueryClient();
|
|
457
459
|
return useMutation({
|
|
458
460
|
mutationFn: async (input: {
|
|
@@ -465,6 +467,7 @@ export function useToggleCommunityReaction() {
|
|
|
465
467
|
}) => {
|
|
466
468
|
const res = await client.post(`/public/community/${input.entityType}/${input.entityId}/react`, {
|
|
467
469
|
profileId,
|
|
470
|
+
communityId,
|
|
468
471
|
emoji: input.emoji,
|
|
469
472
|
});
|
|
470
473
|
return { ...(res.data as ToggleReactionResult), input };
|
|
@@ -472,14 +475,14 @@ export function useToggleCommunityReaction() {
|
|
|
472
475
|
onSuccess: ({ reactions, input }) => {
|
|
473
476
|
setReactionSummary(queryClient, input, reactions);
|
|
474
477
|
// Adding a reaction implicitly joins the space server-side.
|
|
475
|
-
queryClient.invalidateQueries({ queryKey: communityKeys.spaces(
|
|
478
|
+
queryClient.invalidateQueries({ queryKey: communityKeys.spaces(communityId) });
|
|
476
479
|
},
|
|
477
480
|
});
|
|
478
481
|
}
|
|
479
482
|
|
|
480
483
|
/** Report a post or reply to the moderators. */
|
|
481
484
|
export function useReportCommunityContent() {
|
|
482
|
-
const { client, profileId } =
|
|
485
|
+
const { client, profileId, communityId } = useCommunityScope("useReportCommunityContent()");
|
|
483
486
|
return useMutation({
|
|
484
487
|
mutationFn: async (input: {
|
|
485
488
|
entityId: string;
|
|
@@ -488,6 +491,7 @@ export function useReportCommunityContent() {
|
|
|
488
491
|
}) => {
|
|
489
492
|
const res = await client.post(`/public/community/posts/${input.entityId}/report`, {
|
|
490
493
|
profileId,
|
|
494
|
+
communityId,
|
|
491
495
|
entityType: input.entityType,
|
|
492
496
|
reason: input.reason,
|
|
493
497
|
});
|
|
@@ -496,22 +500,22 @@ export function useReportCommunityContent() {
|
|
|
496
500
|
});
|
|
497
501
|
}
|
|
498
502
|
|
|
499
|
-
/** The member's own community profile
|
|
503
|
+
/** The member's own community profile: created on first read (GET /public/community/me). */
|
|
500
504
|
export function useMyCommunityProfile(enabled = true) {
|
|
501
|
-
const { client, profileId } =
|
|
505
|
+
const { client, profileId, communityId } = useCommunityScope("useMyCommunityProfile()");
|
|
502
506
|
return useQuery<CommunityMemberProfile>({
|
|
503
|
-
queryKey: communityKeys.myProfile(
|
|
507
|
+
queryKey: communityKeys.myProfile(communityId),
|
|
504
508
|
queryFn: async () => {
|
|
505
|
-
const res = await client.get("/public/community/me", { params: { profileId } });
|
|
509
|
+
const res = await client.get("/public/community/me", { params: { profileId, communityId } });
|
|
506
510
|
return res.data;
|
|
507
511
|
},
|
|
508
|
-
enabled: enabled && !!profileId && !!client,
|
|
512
|
+
enabled: enabled && !!profileId && !!communityId && !!client,
|
|
509
513
|
});
|
|
510
514
|
}
|
|
511
515
|
|
|
512
516
|
/** Update display name / bio / avatar / digest cadence (PUT /public/community/me). */
|
|
513
517
|
export function useUpdateMyCommunityProfile() {
|
|
514
|
-
const { client, profileId } =
|
|
518
|
+
const { client, profileId, communityId } = useCommunityScope("useUpdateMyCommunityProfile()");
|
|
515
519
|
const queryClient = useQueryClient();
|
|
516
520
|
return useMutation({
|
|
517
521
|
mutationFn: async (input: {
|
|
@@ -520,127 +524,127 @@ export function useUpdateMyCommunityProfile() {
|
|
|
520
524
|
avatarUrl?: string | null;
|
|
521
525
|
digestCadence?: CommunityDigestCadence;
|
|
522
526
|
}) => {
|
|
523
|
-
const res = await client.put("/public/community/me", { profileId, ...input });
|
|
527
|
+
const res = await client.put("/public/community/me", { profileId, communityId, ...input });
|
|
524
528
|
return res.data as CommunityMemberProfile;
|
|
525
529
|
},
|
|
526
530
|
onSuccess: (profile) => {
|
|
527
|
-
queryClient.setQueryData(communityKeys.myProfile(
|
|
528
|
-
queryClient.invalidateQueries({ queryKey: communityKeys.member(
|
|
531
|
+
queryClient.setQueryData(communityKeys.myProfile(communityId), profile);
|
|
532
|
+
queryClient.invalidateQueries({ queryKey: communityKeys.member(communityId, profile?.accountId) });
|
|
529
533
|
},
|
|
530
534
|
});
|
|
531
535
|
}
|
|
532
536
|
|
|
533
537
|
/** Another member's profile + recent activity (filtered to spaces you can read). */
|
|
534
538
|
export function useCommunityMemberProfile(accountId?: string) {
|
|
535
|
-
const { client, profileId } =
|
|
539
|
+
const { client, profileId, communityId } = useCommunityScope("useCommunityMemberProfile()");
|
|
536
540
|
return useQuery<CommunityMemberProfile>({
|
|
537
|
-
queryKey: communityKeys.member(
|
|
541
|
+
queryKey: communityKeys.member(communityId, accountId),
|
|
538
542
|
queryFn: async () => {
|
|
539
|
-
const res = await client.get(`/public/community/profile/${accountId}`, { params: { profileId } });
|
|
543
|
+
const res = await client.get(`/public/community/profile/${accountId}`, { params: { profileId, communityId } });
|
|
540
544
|
return res.data;
|
|
541
545
|
},
|
|
542
|
-
enabled: !!accountId && !!profileId && !!client,
|
|
546
|
+
enabled: !!accountId && !!profileId && !!communityId && !!client,
|
|
543
547
|
});
|
|
544
548
|
}
|
|
545
549
|
|
|
546
550
|
/** In-app inbox (GET /public/community/notifications). Returns { data, unreadCount }. */
|
|
547
551
|
export function useCommunityNotifications({ unreadOnly = false, limit = 50 } = {}) {
|
|
548
|
-
const { client, profileId } =
|
|
552
|
+
const { client, profileId, communityId } = useCommunityScope("useCommunityNotifications()");
|
|
549
553
|
return useQuery<{ data: CommunityNotificationItem[]; unreadCount: number }>({
|
|
550
|
-
queryKey: communityKeys.notifications(
|
|
554
|
+
queryKey: communityKeys.notifications(communityId, unreadOnly),
|
|
551
555
|
queryFn: async () => {
|
|
552
556
|
const res = await client.get("/public/community/notifications", {
|
|
553
|
-
params: { profileId, page: 1, limit, unreadOnly },
|
|
557
|
+
params: { profileId, communityId, page: 1, limit, unreadOnly },
|
|
554
558
|
});
|
|
555
559
|
return res.data;
|
|
556
560
|
},
|
|
557
|
-
enabled: !!profileId && !!client,
|
|
561
|
+
enabled: !!profileId && !!communityId && !!client,
|
|
558
562
|
});
|
|
559
563
|
}
|
|
560
564
|
|
|
561
565
|
/** Unread count for the bell, polled every 30s (same cadence as chat unread counts). */
|
|
562
566
|
export function useCommunityUnreadCount(enabled = true) {
|
|
563
|
-
const { client, profileId } =
|
|
567
|
+
const { client, profileId, communityId } = useCommunityScope("useCommunityUnreadCount()");
|
|
564
568
|
return useQuery<number>({
|
|
565
|
-
queryKey: communityKeys.unread(
|
|
569
|
+
queryKey: communityKeys.unread(communityId),
|
|
566
570
|
queryFn: async () => {
|
|
567
571
|
const res = await client.get("/public/community/notifications", {
|
|
568
|
-
params: { profileId, page: 1, limit: 1, unreadOnly: true },
|
|
572
|
+
params: { profileId, communityId, page: 1, limit: 1, unreadOnly: true },
|
|
569
573
|
});
|
|
570
574
|
return res.data.unreadCount as number;
|
|
571
575
|
},
|
|
572
|
-
enabled: enabled && !!profileId && !!client,
|
|
576
|
+
enabled: enabled && !!profileId && !!communityId && !!client,
|
|
573
577
|
refetchInterval: 30000,
|
|
574
578
|
});
|
|
575
579
|
}
|
|
576
580
|
|
|
577
|
-
/** Mark notifications read
|
|
581
|
+
/** Mark notifications read: omit ids to mark ALL unread as read. */
|
|
578
582
|
export function useMarkCommunityNotificationsRead() {
|
|
579
|
-
const { client, profileId } =
|
|
583
|
+
const { client, profileId, communityId } = useCommunityScope("useMarkCommunityNotificationsRead()");
|
|
580
584
|
const queryClient = useQueryClient();
|
|
581
585
|
return useMutation({
|
|
582
586
|
mutationFn: async (ids?: string[]) => {
|
|
583
|
-
await client.post("/public/community/notifications/mark-read", { profileId, ids });
|
|
587
|
+
await client.post("/public/community/notifications/mark-read", { profileId, communityId, ids });
|
|
584
588
|
},
|
|
585
589
|
onSuccess: () => {
|
|
586
590
|
queryClient.invalidateQueries({ queryKey: ["public-community-notifications"] });
|
|
587
|
-
queryClient.invalidateQueries({ queryKey: communityKeys.unread(
|
|
591
|
+
queryClient.invalidateQueries({ queryKey: communityKeys.unread(communityId) });
|
|
588
592
|
},
|
|
589
593
|
});
|
|
590
594
|
}
|
|
591
595
|
|
|
592
596
|
/** Points leaderboard (GET /public/community/leaderboard?period=all|7|30). */
|
|
593
597
|
export function useCommunityLeaderboard(period: CommunityLeaderboardPeriod = "all", limit = 20) {
|
|
594
|
-
const { client, profileId } =
|
|
598
|
+
const { client, profileId, communityId } = useCommunityScope("useCommunityLeaderboard()");
|
|
595
599
|
return useQuery<CommunityLeaderboardEntry[]>({
|
|
596
|
-
queryKey: communityKeys.leaderboard(
|
|
600
|
+
queryKey: communityKeys.leaderboard(communityId, period),
|
|
597
601
|
queryFn: async () => {
|
|
598
|
-
const res = await client.get("/public/community/leaderboard", { params: { profileId, period, limit } });
|
|
602
|
+
const res = await client.get("/public/community/leaderboard", { params: { profileId, communityId, period, limit } });
|
|
599
603
|
return res.data;
|
|
600
604
|
},
|
|
601
|
-
enabled: !!profileId && !!client,
|
|
605
|
+
enabled: !!profileId && !!communityId && !!client,
|
|
602
606
|
});
|
|
603
607
|
}
|
|
604
608
|
|
|
605
609
|
/**
|
|
606
610
|
* Member directory for @mention autocomplete. There is no dedicated public
|
|
607
611
|
* member-search endpoint yet, so this reads the all-time leaderboard at its max
|
|
608
|
-
* page size (100)
|
|
612
|
+
* page size (100) (every community_profiles row ranked by points) and filters
|
|
609
613
|
* client-side.
|
|
610
614
|
*/
|
|
611
615
|
export function useCommunityMentionDirectory() {
|
|
612
|
-
const { client, profileId } =
|
|
616
|
+
const { client, profileId, communityId } = useCommunityScope("useCommunityMentionDirectory()");
|
|
613
617
|
return useQuery<CommunityLeaderboardEntry[]>({
|
|
614
|
-
queryKey: communityKeys.leaderboard(
|
|
618
|
+
queryKey: communityKeys.leaderboard(communityId, "mention-directory"),
|
|
615
619
|
queryFn: async () => {
|
|
616
620
|
const res = await client.get("/public/community/leaderboard", {
|
|
617
|
-
params: { profileId, period: "all", limit: 100 },
|
|
621
|
+
params: { profileId, communityId, period: "all", limit: 100 },
|
|
618
622
|
});
|
|
619
623
|
return res.data;
|
|
620
624
|
},
|
|
621
|
-
enabled: !!profileId && !!client,
|
|
625
|
+
enabled: !!profileId && !!communityId && !!client,
|
|
622
626
|
staleTime: 60_000,
|
|
623
627
|
});
|
|
624
628
|
}
|
|
625
629
|
|
|
626
630
|
/**
|
|
627
631
|
* FTS over posts + replies (min 2 chars). Scopes to spaces the member can read;
|
|
628
|
-
* pass `spaceId` (a space uuid OR its per-profile slug) to scope to one space
|
|
632
|
+
* pass `spaceId` (a space uuid OR its per-profile slug) to scope to one space:
|
|
629
633
|
* scoping can only narrow, never widen, access. Backward-compatible: called with
|
|
630
634
|
* just a query it searches every accessible space (prior behavior).
|
|
631
635
|
*/
|
|
632
636
|
export function useCommunitySearch(q: string, spaceId?: string) {
|
|
633
|
-
const { client, profileId } =
|
|
637
|
+
const { client, profileId, communityId } = useCommunityScope("useCommunitySearch()");
|
|
634
638
|
const trimmed = q.trim();
|
|
635
639
|
return useQuery<CommunitySearchResults>({
|
|
636
|
-
queryKey: communityKeys.search(
|
|
640
|
+
queryKey: communityKeys.search(communityId, trimmed, spaceId),
|
|
637
641
|
queryFn: async () => {
|
|
638
642
|
const res = await client.get("/public/community/search", {
|
|
639
|
-
params: { profileId, q: trimmed, page: 1, limit: 20, ...(spaceId ? { spaceId } : {}) },
|
|
643
|
+
params: { profileId, communityId, q: trimmed, page: 1, limit: 20, ...(spaceId ? { spaceId } : {}) },
|
|
640
644
|
});
|
|
641
645
|
return res.data;
|
|
642
646
|
},
|
|
643
|
-
enabled: trimmed.length >= 2 && !!profileId && !!client,
|
|
647
|
+
enabled: trimmed.length >= 2 && !!profileId && !!communityId && !!client,
|
|
644
648
|
});
|
|
645
649
|
}
|
|
646
650
|
|
|
@@ -6,12 +6,13 @@ import type {
|
|
|
6
6
|
QuestionnaireAnswer,
|
|
7
7
|
} from "../../types/models";
|
|
8
8
|
import { useForge } from "../../provider/ForgeProvider";
|
|
9
|
+
import { useOptionalCommunityContext } from "../../contexts/CommunityContext";
|
|
9
10
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* The membership invite token this visitor arrived holding, read from the
|
|
13
|
-
* `invite` search param of the CURRENT url (the invite email links to
|
|
14
|
-
* `/i/
|
|
14
|
+
* `invite` search param of the CURRENT url (the invite email links to the
|
|
15
|
+
* community's About page, `/i/communities/<slug>?invite=TOKEN`).
|
|
15
16
|
*
|
|
16
17
|
* Deliberately not stored anywhere: it is a bearer credential, and the durable
|
|
17
18
|
* copy is the email. Returns undefined on the server and when absent.
|
|
@@ -31,10 +32,17 @@ export interface UseGetMembershipTiersOptions {
|
|
|
31
32
|
* `null` forwards nothing even when the url carries one.
|
|
32
33
|
*/
|
|
33
34
|
inviteToken?: string | null;
|
|
35
|
+
/**
|
|
36
|
+
* The community whose tiers to list. Omitted, the surrounding
|
|
37
|
+
* `<CommunityProvider>` is used; outside one, every live tier on the profile
|
|
38
|
+
* comes back (a members-only gate on a product can name tiers from any
|
|
39
|
+
* community). Each tier carries its `communityId` either way.
|
|
40
|
+
*/
|
|
41
|
+
communityId?: string | null;
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
/**
|
|
37
|
-
* `opts.enabled` gates the request on top of the usual profile/client checks
|
|
45
|
+
* `opts.enabled` gates the request on top of the usual profile/client checks:
|
|
38
46
|
* for a caller that only needs tier NAMES when something is actually gated, and
|
|
39
47
|
* must not add a request to every ungated storefront. Defaults to on, so every
|
|
40
48
|
* existing caller is unchanged.
|
|
@@ -44,22 +52,34 @@ export interface UseGetMembershipTiersOptions {
|
|
|
44
52
|
*/
|
|
45
53
|
export function useGetMembershipTiers(opts: UseGetMembershipTiersOptions = {}) {
|
|
46
54
|
const { client, profileId } = useForge();
|
|
55
|
+
const context = useOptionalCommunityContext();
|
|
47
56
|
const inviteToken = opts.inviteToken === undefined ? readMembershipInviteFromUrl() : (opts.inviteToken ?? undefined);
|
|
57
|
+
// `null` asks for the whole profile even inside a provider.
|
|
58
|
+
const communityId = opts.communityId === undefined ? context?.communityId : (opts.communityId ?? undefined);
|
|
59
|
+
// Inside a provider whose slug has not resolved yet, wait rather than list the whole profile.
|
|
60
|
+
const waitingForCommunity = opts.communityId === undefined && !!context && !context.communityId;
|
|
48
61
|
|
|
49
62
|
return useQuery<MembershipTier[]>({
|
|
50
|
-
// The token
|
|
51
|
-
// one every existing cache seed (SSR, specs)
|
|
52
|
-
|
|
63
|
+
// The community and the token are appended only when present, so the
|
|
64
|
+
// profile-wide key stays the one every existing cache seed (SSR, specs)
|
|
65
|
+
// already writes.
|
|
66
|
+
queryKey: [
|
|
67
|
+
"membership-tiers",
|
|
68
|
+
profileId,
|
|
69
|
+
...(communityId ? [{ communityId }] : []),
|
|
70
|
+
...(inviteToken ? [inviteToken] : []),
|
|
71
|
+
],
|
|
53
72
|
queryFn: async () => {
|
|
54
73
|
const res = await client.get("/public/membership-tiers", {
|
|
55
74
|
params: {
|
|
56
75
|
profileId: profileId,
|
|
76
|
+
...(communityId ? { communityId } : {}),
|
|
57
77
|
...(inviteToken ? { inviteToken } : {}),
|
|
58
78
|
},
|
|
59
79
|
});
|
|
60
80
|
return res.data;
|
|
61
81
|
},
|
|
62
|
-
enabled: !!profileId && !!client && opts.enabled !== false,
|
|
82
|
+
enabled: !!profileId && !!client && !waitingForCommunity && opts.enabled !== false,
|
|
63
83
|
});
|
|
64
84
|
}
|
|
65
85
|
|
|
@@ -109,6 +129,10 @@ export type ApplyForMembershipInput = {
|
|
|
109
129
|
questionnaire?: QuestionnaireAnswer[];
|
|
110
130
|
/** Where Stripe returns after a redirect-based card confirmation. */
|
|
111
131
|
returnUrl?: string;
|
|
132
|
+
/** A per-email invite token (`?invite=`). The community comes from the tier. */
|
|
133
|
+
inviteToken?: string;
|
|
134
|
+
/** A shareable invite link token (`?join=`). */
|
|
135
|
+
joinToken?: string;
|
|
112
136
|
};
|
|
113
137
|
|
|
114
138
|
/** Invalidate everything an application changes: the tier cards and the fan's list. */
|
|
@@ -118,6 +142,7 @@ function useInvalidateApplications() {
|
|
|
118
142
|
Promise.all([
|
|
119
143
|
queryClient.invalidateQueries({ queryKey: ["membership-tiers"] }),
|
|
120
144
|
queryClient.invalidateQueries({ queryKey: ["my-membership-applications"] }),
|
|
145
|
+
queryClient.invalidateQueries({ queryKey: ["public-communities"] }),
|
|
121
146
|
]);
|
|
122
147
|
}
|
|
123
148
|
|