@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.
Files changed (94) hide show
  1. package/package.json +1 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CommunityContext.tsx +192 -0
  4. package/src/data/queries/useChat.ts +42 -39
  5. package/src/data/queries/useCommunities.ts +343 -0
  6. package/src/data/queries/useCommunity.ts +93 -89
  7. package/src/data/queries/useMembership.ts +32 -7
  8. package/src/data/queries/useMembershipCalls.ts +116 -7
  9. package/src/data/queries/usePostCollections.ts +28 -16
  10. package/src/data/queries/usePosts.ts +16 -14
  11. package/src/data/queries/useSubscriptions.ts +22 -8
  12. package/src/i18n/de.json +145 -1
  13. package/src/i18n/en.json +145 -1
  14. package/src/index.ts +19 -7
  15. package/src/runtime/pages.spec.ts +31 -0
  16. package/src/runtime/pages.ts +21 -22
  17. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  18. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  19. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  20. package/src/ui/format/membershipGate.ts +19 -15
  21. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  22. package/src/ui/headless/_tests/communityHarness.tsx +205 -0
  23. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  24. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  25. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +382 -0
  26. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +151 -0
  27. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +286 -0
  28. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  29. package/src/ui/headless/auth/useSignupForm.ts +10 -2
  30. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  31. package/src/ui/headless/communities/index.ts +55 -0
  32. package/src/ui/headless/communities/paths.ts +36 -0
  33. package/src/ui/headless/communities/useCommunity.ts +140 -0
  34. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  35. package/src/ui/headless/communities/useCommunityHome.ts +88 -0
  36. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  37. package/src/ui/headless/communities/useCommunityJoin.ts +444 -0
  38. package/src/ui/headless/communities/useCommunityMembership.ts +136 -0
  39. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  40. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  41. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  42. package/src/ui/headless/index.ts +8 -4
  43. package/src/ui/headless/memberHome/index.ts +1 -1
  44. package/src/ui/headless/memberHome/useMemberHomeNav.ts +44 -42
  45. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  46. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  47. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  48. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  49. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  50. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  51. package/src/ui/headless/posts/upsell.ts +54 -8
  52. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  53. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  54. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  55. package/src/ui/headless/posts/usePostItem.ts +6 -5
  56. package/src/ui/index.ts +17 -12
  57. package/src/ui/styled/AccountDashboard.tsx +48 -256
  58. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  59. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  60. package/src/ui/styled/MembershipTiers.tsx +49 -11
  61. package/src/ui/styled/Paywall.tsx +1 -1
  62. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +1 -1
  63. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  64. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  65. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  66. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  67. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  68. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  69. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  70. package/src/ui/styled/communities/CommunityAbout.tsx +140 -0
  71. package/src/ui/styled/communities/CommunityHome.tsx +175 -0
  72. package/src/ui/styled/communities/CommunityJoinSheet.tsx +444 -0
  73. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +133 -0
  74. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  75. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  76. package/src/ui/styled/communities/MyMembershipsList.tsx +89 -0
  77. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  78. package/src/ui/styled/communities/index.ts +10 -0
  79. package/src/ui/styled/communities/shared.tsx +149 -0
  80. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  81. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  82. package/src/ui/styled/forge-utilities.css +19 -0
  83. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  84. package/src/ui/styled/members/MemberCallsCalendar.tsx +142 -10
  85. package/src/ui/styled/members/MemberHomeLayout.tsx +35 -17
  86. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  87. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  88. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  89. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  90. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  91. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  92. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  93. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  94. 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 { useForge } from "../../provider/ForgeProvider";
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 — empty array = open to every member. */
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 — mirrors chatKeys in useChat.ts.
237
+ // same caches the data hooks populate: mirrors chatKeys in useChat.ts.
238
238
  export const communityKeys = {
239
- spaces: (profileId?: string) => ["public-community-spaces", profileId] as const,
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: (profileId?: string) => ["public-community-my-profile", profileId] as const,
245
- member: (profileId?: string, accountId?: string) => ["public-community-member", profileId, accountId] as const,
246
- notifications: (profileId?: string, unreadOnly?: boolean) =>
247
- ["public-community-notifications", profileId, unreadOnly] as const,
248
- unread: (profileId?: string) => ["public-community-unread", profileId] as const,
249
- leaderboard: (profileId?: string, period?: string) => ["public-community-leaderboard", profileId, period] as const,
250
- search: (profileId?: string, q?: string, spaceId?: string) =>
251
- ["public-community-search", profileId, q, spaceId ?? null] as const,
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 } = useForge();
260
+ const { client, profileId, communityId } = useCommunityScope("useCommunitySpaces()");
259
261
  return useQuery<CommunitySpace[]>({
260
- queryKey: communityKeys.spaces(profileId),
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
- profileId: string | undefined,
279
+ communityId: string | undefined,
278
280
  spaceId: string,
279
281
  membership: SpaceMembershipResult,
280
282
  ) {
281
- queryClient.setQueryData(communityKeys.spaces(profileId), (old: CommunitySpace[] | undefined) =>
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 — joining opts into new-post notifications/digests
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 } = useForge();
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, profileId, spaceId, membership);
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 } = useForge();
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, profileId, spaceId, membership);
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 } = useForge();
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 } = useForge();
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 — we request the minimum (limit=1) and let the dedicated
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 } = useForge();
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 — assemble the tree with
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 } = useForge();
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 — build one from plain text with `buildLexicalState`. */
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 } = useForge();
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(profileId) });
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 — optionally nested under another reply. */
429
+ /** Reply to a post: optionally nested under another reply. */
428
430
  export function useCreateCommunityReply(postId?: string) {
429
- const { client, profileId } = useForge();
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(profileId) });
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 } = useForge();
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(profileId) });
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 } = useForge();
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 — created on first read (GET /public/community/me). */
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 } = useForge();
505
+ const { client, profileId, communityId } = useCommunityScope("useMyCommunityProfile()");
502
506
  return useQuery<CommunityMemberProfile>({
503
- queryKey: communityKeys.myProfile(profileId),
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 } = useForge();
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(profileId), profile);
528
- queryClient.invalidateQueries({ queryKey: communityKeys.member(profileId, profile?.accountId) });
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 } = useForge();
539
+ const { client, profileId, communityId } = useCommunityScope("useCommunityMemberProfile()");
536
540
  return useQuery<CommunityMemberProfile>({
537
- queryKey: communityKeys.member(profileId, accountId),
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 } = useForge();
552
+ const { client, profileId, communityId } = useCommunityScope("useCommunityNotifications()");
549
553
  return useQuery<{ data: CommunityNotificationItem[]; unreadCount: number }>({
550
- queryKey: communityKeys.notifications(profileId, unreadOnly),
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 } = useForge();
567
+ const { client, profileId, communityId } = useCommunityScope("useCommunityUnreadCount()");
564
568
  return useQuery<number>({
565
- queryKey: communityKeys.unread(profileId),
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 — omit ids to mark ALL unread as read. */
581
+ /** Mark notifications read: omit ids to mark ALL unread as read. */
578
582
  export function useMarkCommunityNotificationsRead() {
579
- const { client, profileId } = useForge();
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(profileId) });
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 } = useForge();
598
+ const { client, profileId, communityId } = useCommunityScope("useCommunityLeaderboard()");
595
599
  return useQuery<CommunityLeaderboardEntry[]>({
596
- queryKey: communityKeys.leaderboard(profileId, period),
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) — every community_profiles row ranked by points — and filters
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 } = useForge();
616
+ const { client, profileId, communityId } = useCommunityScope("useCommunityMentionDirectory()");
613
617
  return useQuery<CommunityLeaderboardEntry[]>({
614
- queryKey: communityKeys.leaderboard(profileId, "mention-directory"),
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 } = useForge();
637
+ const { client, profileId, communityId } = useCommunityScope("useCommunitySearch()");
634
638
  const trimmed = q.trim();
635
639
  return useQuery<CommunitySearchResults>({
636
- queryKey: communityKeys.search(profileId, trimmed, spaceId),
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/membership/checkout?membershipTierId=X&invite=TOKEN`).
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 is appended only when present, so the no-invite key stays the
51
- // one every existing cache seed (SSR, specs) already writes.
52
- queryKey: inviteToken ? ["membership-tiers", profileId, inviteToken] : ["membership-tiers", profileId],
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