@tribe-nest/forge 3.58.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,12 +1,14 @@
1
1
  import { useQuery } from "@tanstack/react-query";
2
+ import type { AxiosInstance } from "axios";
2
3
  import { useForge } from "../../provider/ForgeProvider";
4
+ import { MissingCommunityProviderError, useOptionalCommunityContext } from "../../contexts/CommunityContext";
3
5
 
4
6
  /**
5
7
  * The viewer's own call calendar (`GET /public/scheduled-calls/mine`).
6
8
  *
7
9
  * Lives in Forge rather than in either app because BOTH rendering surfaces need
8
- * exactly this - the site-starter's `/i/members/calendar` and the client app's
9
- * `/i/members/calendar`, and now the classroom and cohort pages on both - and a
10
+ * exactly this - every community's calendar tab and the classroom and cohort
11
+ * pages - and a
10
12
  * second copy is how the two drift, as `useMyTickets` and `useMyBookings`
11
13
  * already document.
12
14
  *
@@ -37,6 +39,90 @@ import { useForge } from "../../provider/ForgeProvider";
37
39
 
38
40
  export type MembershipCallParticipation = "open" | "host_only";
39
41
 
42
+ /** One speaker turn. Times are ms from the start of the recording, so they seek the video. */
43
+ export interface MemberCallTranscriptSegment {
44
+ name: string;
45
+ startMs: number;
46
+ endMs: number;
47
+ text: string;
48
+ }
49
+
50
+ /**
51
+ * A recorded call's transcript, as a member sees it: the finished thing, or
52
+ * that it is being prepared. Never a failure: the member is shown nothing at
53
+ * all rather than a reason that is the business's to deal with. Null on the
54
+ * recording when there is nothing to show.
55
+ */
56
+ export type MemberCallTranscript =
57
+ | { status: "processing" }
58
+ | {
59
+ status: "ready";
60
+ /** Null when the summary could not be written; the transcript still stands. */
61
+ summary: MemberCallTranscriptSummary | null;
62
+ };
63
+
64
+ export interface MemberCallTranscriptSummary {
65
+ title: string;
66
+ summary: string;
67
+ decisions: string[];
68
+ actionItems: string[];
69
+ }
70
+
71
+ /**
72
+ * One finished transcript with every line. NOT on the calendar payload: a
73
+ * two-hour meeting is thousands of lines, so they are read one transcript at a
74
+ * time, when a member opens it or downloads it.
75
+ */
76
+ export interface MemberCallTranscriptDetail {
77
+ recordingId: string;
78
+ callId: string;
79
+ title: string;
80
+ startsAt: string;
81
+ timezone: string | null;
82
+ durationSeconds: number | null;
83
+ /** Signed for SIXTY seconds. Refetch for a fresh one when playback fails. */
84
+ watchUrl: string | null;
85
+ transcript: {
86
+ status: "ready";
87
+ language: string | null;
88
+ segments: MemberCallTranscriptSegment[];
89
+ summary: MemberCallTranscriptSummary | null;
90
+ };
91
+ }
92
+
93
+ export const memberCallTranscriptKey = (profileId: string | undefined, recordingId: string) =>
94
+ ["membership-calls", "transcript", profileId, recordingId] as const;
95
+
96
+ /** Read one transcript. Shared by the viewer and by Download, so both hit one cache entry. */
97
+ export const fetchMemberCallTranscript = async (
98
+ client: AxiosInstance,
99
+ profileId: string | undefined,
100
+ recordingId: string,
101
+ ): Promise<MemberCallTranscriptDetail> => {
102
+ const res = await client.get(`/public/scheduled-calls/mine/recordings/${recordingId}/transcript`, {
103
+ params: { profileId },
104
+ });
105
+ return res.data as MemberCallTranscriptDetail;
106
+ };
107
+
108
+ /**
109
+ * One recording's transcript and a fresh video address, for the viewer.
110
+ *
111
+ * Never refetched in the background: the lines do not change, and a refetch
112
+ * would swap the video address under a playing player. The viewer refetches on
113
+ * purpose when the sixty-second address lapses.
114
+ */
115
+ export function useMemberCallTranscript(recordingId: string | null) {
116
+ const { client, profileId } = useForge();
117
+ return useQuery<MemberCallTranscriptDetail>({
118
+ queryKey: memberCallTranscriptKey(profileId, recordingId ?? ""),
119
+ queryFn: () => fetchMemberCallTranscript(client, profileId, recordingId!),
120
+ enabled: !!profileId && !!client && !!recordingId,
121
+ staleTime: Infinity,
122
+ refetchOnWindowFocus: false,
123
+ });
124
+ }
125
+
40
126
  export interface MyMembershipCall {
41
127
  id: string;
42
128
  title: string;
@@ -84,6 +170,8 @@ export interface MyMembershipCall {
84
170
  durationSeconds: number | null;
85
171
  /** Signed per request and short-lived. Null while `processing`. */
86
172
  watchUrl: string | null;
173
+ /** Who said what, and a summary. Same access rule as the video. */
174
+ transcript: MemberCallTranscript | null;
87
175
  } | null;
88
176
  }
89
177
 
@@ -91,6 +179,21 @@ export const membershipCallKeys = {
91
179
  mine: (profileId: string | undefined) => ["membership-calls", "mine", profileId] as const,
92
180
  };
93
181
 
182
+ /**
183
+ * The community a call read is scoped to, or null for a course or cohort scope.
184
+ *
185
+ * A community calendar must come from `<CommunityProvider>`: a fan in two
186
+ * communities must never see one's calls on the other's calendar. A course or
187
+ * cohort scope names its own audience and belongs to no community, so it needs
188
+ * no provider. Anything else is a developer error, and it throws.
189
+ */
190
+ function useCallScopeCommunity(hookName: string, courseScoped: boolean): string | null | undefined {
191
+ const community = useOptionalCommunityContext();
192
+ if (courseScoped) return null;
193
+ if (!community) throw new MissingCommunityProviderError(hookName);
194
+ return community.communityId;
195
+ }
196
+
94
197
  /**
95
198
  * @param options.from Inclusive lower bound. Absent means "from now on", which is
96
199
  * what a caller wanting only the next few calls sends.
@@ -121,6 +224,8 @@ export function useMyMembershipCalls(options?: {
121
224
  const to = options?.to?.toISOString();
122
225
  const courseId = options?.courseId || undefined;
123
226
  const cohortId = options?.cohortId || undefined;
227
+ // null = a course or cohort scope; a string = this community; undefined = still resolving.
228
+ const communityId = useCallScopeCommunity("useMyMembershipCalls()", !!(courseId || cohortId));
124
229
  return useQuery<MyMembershipCall[]>({
125
230
  // The WINDOW is part of the key: paging back a month is a different question
126
231
  // with a different answer, and sharing one cache entry would show February's
@@ -134,14 +239,15 @@ export function useMyMembershipCalls(options?: {
134
239
  to ?? "open",
135
240
  courseId ?? "any-course",
136
241
  cohortId ?? "any-cohort",
242
+ communityId ?? "no-community",
137
243
  ],
138
244
  queryFn: async () => {
139
245
  const res = await client.get("/public/scheduled-calls/mine", {
140
- params: { profileId, from, to, limit: options?.limit, courseId, cohortId },
246
+ params: { profileId, from, to, limit: options?.limit, courseId, cohortId, communityId: communityId ?? undefined },
141
247
  });
142
248
  return res.data;
143
249
  },
144
- enabled: !!profileId && !!client && options?.enabled !== false,
250
+ enabled: !!profileId && !!client && communityId !== undefined && options?.enabled !== false,
145
251
  /**
146
252
  * A call opens fifteen minutes before it starts, and the member watching
147
253
  * this page is waiting for exactly that. Without a refetch the Join button
@@ -175,19 +281,22 @@ export interface MyMembershipCallRecording {
175
281
  * for long. Null while `status` is `processing`.
176
282
  */
177
283
  watchUrl: string | null;
284
+ /** Who said what, and a summary. Same access rule as the video. */
285
+ transcript: MemberCallTranscript | null;
178
286
  }
179
287
 
180
288
  export function useMyMembershipCallRecordings(options?: { limit?: number; enabled?: boolean }) {
181
289
  const { client, profileId } = useForge();
290
+ const communityId = useCallScopeCommunity("useMyMembershipCallRecordings()", false);
182
291
  return useQuery<MyMembershipCallRecording[]>({
183
- queryKey: ["membership-calls", "recordings", profileId],
292
+ queryKey: ["membership-calls", "recordings", profileId, communityId],
184
293
  queryFn: async () => {
185
294
  const res = await client.get("/public/scheduled-calls/mine/recordings", {
186
- params: { profileId, limit: options?.limit },
295
+ params: { profileId, communityId, limit: options?.limit },
187
296
  });
188
297
  return res.data;
189
298
  },
190
- enabled: !!profileId && !!client && options?.enabled !== false,
299
+ enabled: !!profileId && !!communityId && !!client && options?.enabled !== false,
191
300
  /**
192
301
  * Slower than the upcoming list: nothing here changes on a fifteen-minute
193
302
  * boundary, and the only transition worth catching is `processing` ->
@@ -1,5 +1,5 @@
1
1
  import type { IPublicPostCollection, PaginatedData } from "../../types/models";
2
- import { useForge } from "../../provider/ForgeProvider";
2
+ import { useCommunityScope } from "../../contexts/CommunityContext";
3
3
  import { useQuery } from "@tanstack/react-query";
4
4
 
5
5
  export interface GetPostCollectionsParams {
@@ -8,14 +8,15 @@ export interface GetPostCollectionsParams {
8
8
  }
9
9
 
10
10
  export function useGetPostCollections(params?: GetPostCollectionsParams) {
11
- const { client, profileId } = useForge();
11
+ const { client, profileId, communityId } = useCommunityScope("useGetPostCollections()");
12
12
 
13
13
  return useQuery<PaginatedData<IPublicPostCollection>>({
14
- queryKey: ["post-collections", profileId, params],
14
+ queryKey: ["post-collections", communityId, params],
15
15
  queryFn: async () => {
16
16
  const res = await client.get("/public/post-collections", {
17
17
  params: {
18
- profileId: profileId,
18
+ profileId,
19
+ communityId,
19
20
  page: params?.page || 1,
20
21
  limit: 10,
21
22
  filter: {
@@ -25,24 +26,35 @@ export function useGetPostCollections(params?: GetPostCollectionsParams) {
25
26
  });
26
27
  return res.data;
27
28
  },
28
- enabled: !!profileId && !!client,
29
+ enabled: !!profileId && !!communityId && !!client,
29
30
  });
30
31
  }
31
32
 
33
+ /**
34
+ * One collection, by id or slug. A collection that is missing, archived or in
35
+ * another community answers 404, which is an ANSWER, not a failure: this
36
+ * resolves `null` for it (and does not retry), so a page can say "not found"
37
+ * rather than falling over.
38
+ */
32
39
  export function useGetPostCollection(collectionId?: string) {
33
- const { client, profileId } = useForge();
40
+ const { client, profileId, communityId } = useCommunityScope("useGetPostCollection()");
34
41
 
35
- return useQuery<IPublicPostCollection>({
36
- queryKey: ["post-collection", profileId, collectionId],
42
+ return useQuery<IPublicPostCollection | null>({
43
+ queryKey: ["post-collection", communityId, collectionId],
37
44
  queryFn: async () => {
38
- const res = await client.get(`/public/post-collections/${collectionId}`, {
39
- params: {
40
- profileId: profileId,
41
- },
42
- });
43
- return res.data;
45
+ try {
46
+ const res = await client.get(`/public/post-collections/${collectionId}`, {
47
+ params: {
48
+ profileId,
49
+ communityId,
50
+ },
51
+ });
52
+ return res.data ?? null;
53
+ } catch (error) {
54
+ if ((error as { response?: { status?: number } })?.response?.status === 404) return null;
55
+ throw error;
56
+ }
44
57
  },
45
- enabled: !!profileId && !!client && !!collectionId,
58
+ enabled: !!profileId && !!communityId && !!client && !!collectionId,
46
59
  });
47
60
  }
48
-
@@ -1,5 +1,5 @@
1
1
  import type { IPublicPost, PaginatedData } from "../../types/models";
2
- import { useForge } from "../../provider/ForgeProvider";
2
+ import { useCommunityScope } from "../../contexts/CommunityContext";
3
3
  import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
4
4
 
5
5
  export interface GetPostsParams {
@@ -10,14 +10,15 @@ export interface GetPostsParams {
10
10
  }
11
11
 
12
12
  export function useGetPosts(params?: GetPostsParams) {
13
- const { client, profileId } = useForge();
13
+ const { client, profileId, communityId } = useCommunityScope("useGetPosts()");
14
14
 
15
15
  return useQuery<PaginatedData<IPublicPost>>({
16
- queryKey: ["posts", profileId, params],
16
+ queryKey: ["posts", communityId, params],
17
17
  queryFn: async () => {
18
18
  const res = await client.get("/public/posts", {
19
19
  params: {
20
- profileId: profileId,
20
+ profileId,
21
+ communityId,
21
22
  page: params?.page || 1,
22
23
  limit: 10,
23
24
  filter: {
@@ -29,7 +30,7 @@ export function useGetPosts(params?: GetPostsParams) {
29
30
  });
30
31
  return res.data;
31
32
  },
32
- enabled: !!profileId && !!client,
33
+ enabled: !!profileId && !!communityId && !!client,
33
34
  });
34
35
  }
35
36
 
@@ -46,19 +47,20 @@ export interface GetPostsInfiniteParams {
46
47
  * ships media for locked posts, so paging is safe for any auth state.
47
48
  */
48
49
  export function useGetPostsInfinite(params?: GetPostsInfiniteParams) {
49
- const { client, profileId } = useForge();
50
+ const { client, profileId, communityId } = useCommunityScope("useGetPostsInfinite()");
50
51
  const limit = params?.pageSize ?? 10;
51
52
 
52
53
  return useInfiniteQuery<PaginatedData<IPublicPost>>({
53
54
  queryKey: [
54
55
  "posts-infinite",
55
- profileId,
56
+ communityId,
56
57
  { query: params?.query, type: params?.type, membershipTierId: params?.membershipTierId, limit },
57
58
  ],
58
59
  queryFn: async ({ pageParam = 1 }) => {
59
60
  const res = await client.get("/public/posts", {
60
61
  params: {
61
- profileId: profileId,
62
+ profileId,
63
+ communityId,
62
64
  page: pageParam,
63
65
  limit,
64
66
  filter: {
@@ -72,23 +74,23 @@ export function useGetPostsInfinite(params?: GetPostsInfiniteParams) {
72
74
  },
73
75
  initialPageParam: 1,
74
76
  getNextPageParam: (lastPage) => (lastPage.hasNextPage ? (lastPage.nextPage ?? lastPage.page + 1) : undefined),
75
- enabled: !!profileId && !!client,
77
+ enabled: !!profileId && !!communityId && !!client,
76
78
  });
77
79
  }
78
80
 
79
81
  /**
80
- * The member's saved posts (GET /public/posts/saved — requires auth). The
82
+ * The member's saved posts (GET /public/posts/saved: requires auth). The
81
83
  * endpoint returns a single `{ data, total }` page.
82
84
  */
83
85
  export function useGetSavedPosts(enabled = true) {
84
- const { client, profileId } = useForge();
86
+ const { client, profileId, communityId } = useCommunityScope("useGetSavedPosts()");
85
87
 
86
88
  return useQuery<{ data: IPublicPost[]; total: number }>({
87
- queryKey: ["posts-saved", profileId],
89
+ queryKey: ["posts-saved", communityId],
88
90
  queryFn: async () => {
89
- const res = await client.get("/public/posts/saved", { params: { profileId } });
91
+ const res = await client.get("/public/posts/saved", { params: { profileId, communityId } });
90
92
  return res.data;
91
93
  },
92
- enabled: !!profileId && !!client && enabled,
94
+ enabled: !!profileId && !!communityId && !!client && enabled,
93
95
  });
94
96
  }
@@ -1,4 +1,5 @@
1
1
  import { useForge } from "../../provider/ForgeProvider";
2
+ import { useOptionalCommunityContext } from "../../contexts/CommunityContext";
2
3
  import { useMutation } from "@tanstack/react-query";
3
4
  import { usePublicAuth } from "../../contexts/PublicAuthContext";
4
5
  import type { QuestionnaireAnswer } from "../../types/models";
@@ -20,6 +21,8 @@ export type CreateSubscriptionInput = {
20
21
  * invite must be for the signed-in email); ignored on an open tier.
21
22
  */
22
23
  inviteToken?: string;
24
+ /** A shareable invite link token (`?join=`); opens an invite-only tier, still charges a paid one. */
25
+ joinToken?: string;
23
26
  /** Answers to the tier's questions. Required by the server when the tier has any. */
24
27
  questionnaire?: QuestionnaireAnswer[];
25
28
  };
@@ -55,6 +58,8 @@ export type CreateSubscriptionResult = {
55
58
  * charged today, and the first charge is on `endsAt`.
56
59
  */
57
60
  trial?: { days: number; endsAt: string } | null;
61
+ /** The slug of the community the tier belongs to, so the caller can land the fan inside it. */
62
+ communitySlug?: string;
58
63
  };
59
64
 
60
65
  /** Subscribe to a paid tier, or move an existing subscription onto another one. */
@@ -73,10 +78,12 @@ export function useCreateSubscription() {
73
78
  export function useCreateFreeSubscription() {
74
79
  const { client, profileId } = useForge();
75
80
 
76
- return useMutation<unknown, unknown, {
81
+ return useMutation<{ communitySlug?: string } | undefined, unknown, {
77
82
  membershipTierId: string;
78
83
  attributionRefId?: string;
79
84
  inviteToken?: string;
85
+ /** A shareable invite link token (`?join=`). */
86
+ joinToken?: string;
80
87
  /** Answers to the tier's questions. Required by the server when the tier has any. */
81
88
  questionnaire?: QuestionnaireAnswer[];
82
89
  }>({
@@ -89,7 +96,7 @@ export function useCreateFreeSubscription() {
89
96
 
90
97
  export type CancelMembershipResult = {
91
98
  membershipId: string;
92
- /** The status AFTER cancelling — still `active` while a paid period runs out. */
99
+ /** The status AFTER cancelling: still `active` while a paid period runs out. */
93
100
  status: string;
94
101
  /** True when the cancellation was scheduled rather than applied immediately. */
95
102
  cancelAtPeriodEnd: boolean;
@@ -104,7 +111,7 @@ export type CancelMembershipResult = {
104
111
  * schedules it for the end of the period they have already paid for and the
105
112
  * member keeps their benefits until `accessUntil`. Free tiers (and any
106
113
  * membership with nothing paid up) end immediately. Works for every membership
107
- * the member can still use — including one whose card is failing, which used to
114
+ * the member can still use: including one whose card is failing, which used to
108
115
  * be rejected with an error the UI never showed.
109
116
  */
110
117
  export function useCancelMembership() {
@@ -126,7 +133,7 @@ export function useCancelMembership() {
126
133
  *
127
134
  * Every membership-aware surface should use this instead of reading
128
135
  * `user.membership.status`. `access` answers "can they see it", `message`
129
- * answers "what do we tell them" — two different questions, and conflating
136
+ * answers "what do we tell them": two different questions, and conflating
130
137
  * them is what locked past_due members out while calling them "Cancelled".
131
138
  */
132
139
  export function useMembershipAccess(): {
@@ -143,12 +150,12 @@ export function useMembershipAccess(): {
143
150
  *
144
151
  * This is the other half of keeping access through `past_due`: the grace window
145
152
  * is only a recovery window if the member can actually fix their card inside
146
- * it. Nothing about the caller is sent — the server derives the provider
153
+ * it. Nothing about the caller is sent: the server derives the provider
147
154
  * customer from the session, because a customer id in a request body is a
148
155
  * cross-tenant leak waiting to happen.
149
156
  *
150
157
  * `returnUrl` defaults to the current page so the member lands back where they
151
- * were. The returned `url` is a one-time link — redirect, never render it.
158
+ * were. The returned `url` is a one-time link: redirect, never render it.
152
159
  */
153
160
  export function useOpenBillingPortal() {
154
161
  const { client, profileId } = useForge();
@@ -170,14 +177,21 @@ export function useOpenBillingPortal() {
170
177
  /**
171
178
  * Reconcile the most recent subscription after a checkout return.
172
179
  * Returns the latest subscription status (e.g. "active").
180
+ *
181
+ * A fan can subscribe in several communities of one creator, so "the latest"
182
+ * is the latest IN A COMMUNITY: pass `communityId`, or it is read from the
183
+ * surrounding `<CommunityProvider>`.
173
184
  */
174
185
  export function useConfirmLatestSubscription() {
175
186
  const { client, profileId } = useForge();
187
+ const contextCommunityId = useOptionalCommunityContext()?.communityId;
176
188
 
177
- return useMutation<{ status: string }, unknown, void>({
178
- mutationFn: async () => {
189
+ return useMutation<{ status: string }, unknown, { communityId?: string } | void>({
190
+ mutationFn: async (input) => {
191
+ const communityId = (input && input.communityId) || contextCommunityId;
179
192
  const res = await client.post("/public/payments/subscriptions/confirm-latest", {
180
193
  profileId,
194
+ ...(communityId ? { communityId } : {}),
181
195
  });
182
196
  return res.data;
183
197
  },
package/src/i18n/de.json CHANGED
@@ -655,6 +655,118 @@
655
655
  "forge.cohort_page.date_range": "{start} bis {end}",
656
656
  "forge.cohort_page.date_tbd": "Noch offen",
657
657
  "forge.cohort_page.not_found": "Kohorte nicht gefunden.",
658
+ "forge.communities_list.empty": "Es gibt noch keine Communitys zum Beitreten.",
659
+ "forge.communities_list.heading": "Communitys",
660
+ "forge.communities_list.member_count": "{count} Mitglieder",
661
+ "forge.community_about.apply": "Beitritt beantragen",
662
+ "forge.community_about.current_tier": "Deine aktuelle Stufe",
663
+ "forge.community_about.go_inside": "Zur Community",
664
+ "forge.community_about.join": "Beitreten",
665
+ "forge.community_about.join_free": "Kostenlos beitreten",
666
+ "forge.community_about.join_from": "Beitreten ab {amount}/Monat",
667
+ "forge.community_about.join_from_yearly": "Beitreten ab {amount}/Jahr",
668
+ "forge.community_about.join_tier": "Beitreten",
669
+ "forge.community_about.member_count": "{count} Mitglieder",
670
+ "forge.community_about.not_found": "Diese Community wurde nicht gefunden.",
671
+ "forge.community_about.pending": "Dein Antrag wird geprüft.",
672
+ "forge.community_about.tiers_title": "Mitgliedschaftsstufen",
673
+ "forge.community_about.view_application": "Deinen Antrag ansehen",
674
+ "forge.community_home.confirming": "Deine Mitgliedschaft wird bestätigt...",
675
+ "forge.community_home.dismiss": "Schließen",
676
+ "forge.community_home.join": "Beitreten",
677
+ "forge.community_home.membership": "Mitgliedschaft",
678
+ "forge.community_home.not_found": "Diese Community wurde nicht gefunden.",
679
+ "forge.community_home.upgrade": "Upgrade",
680
+ "forge.community_home.welcome": "Willkommen bei {community_name}!",
681
+ "forge.community_join_sheet.accept_terms": "Ich akzeptiere die AGB und die Datenschutzerklärung",
682
+ "forge.community_join_sheet.already_member": "Du bist bereits Mitglied.",
683
+ "forge.community_join_sheet.amount_below_minimum": "Der Mindestbetrag ist {minimum}.",
684
+ "forge.community_join_sheet.amount_label": "Wähle deinen Betrag (mindestens {minimum})",
685
+ "forge.community_join_sheet.amount_not_positive": "Gib einen Betrag über null ein.",
686
+ "forge.community_join_sheet.answer_required": "Bitte beantworte: {question}",
687
+ "forge.community_join_sheet.apply": "Beitritt beantragen",
688
+ "forge.community_join_sheet.aria_close": "Schließen",
689
+ "forge.community_join_sheet.back": "Zurück",
690
+ "forge.community_join_sheet.billing_cycle": "Abrechnungszeitraum",
691
+ "forge.community_join_sheet.card_application": "Karte für deinen Antrag hinterlegen",
692
+ "forge.community_join_sheet.card_application_help": "Deine Karte wird gespeichert, nicht belastet. Sie wird nur belastet, wenn dein Antrag angenommen wird.",
693
+ "forge.community_join_sheet.card_application_help_label": "Wann wird meine Karte belastet?",
694
+ "forge.community_join_sheet.card_payment": "{amount} bezahlen",
695
+ "forge.community_join_sheet.card_trial": "Karte hinterlegen und kostenlose Testphase starten",
696
+ "forge.community_join_sheet.change_tier": "Stufe wechseln",
697
+ "forge.community_join_sheet.choose_tier": "Wähle eine Stufe",
698
+ "forge.community_join_sheet.code": "6-stelliger Code",
699
+ "forge.community_join_sheet.code_sent": "Wir haben einen 6-stelligen Code an {email} gesendet.",
700
+ "forge.community_join_sheet.continue": "Weiter",
701
+ "forge.community_join_sheet.email": "E-Mail",
702
+ "forge.community_join_sheet.email_mismatch": "Diese Einladung gilt für {email}. Du bist als {signed_in_email} angemeldet.",
703
+ "forge.community_join_sheet.first_name": "Vorname",
704
+ "forge.community_join_sheet.invite_expired": "Diese Einladung ist abgelaufen. Du kannst unten trotzdem beitreten.",
705
+ "forge.community_join_sheet.invite_not_found": "Dieser Einladungslink ist ungültig. Du kannst unten trotzdem beitreten.",
706
+ "forge.community_join_sheet.invite_revoked": "Diese Einladung wurde zurückgezogen. Du kannst unten trotzdem beitreten.",
707
+ "forge.community_join_sheet.invite_used": "Diese Einladung wurde bereits verwendet. Du kannst unten trotzdem beitreten.",
708
+ "forge.community_join_sheet.invite_used_up": "Für diesen Einladungslink sind keine Plätze mehr frei. Du kannst unten trotzdem beitreten.",
709
+ "forge.community_join_sheet.join_for": "Für {amount} beitreten",
710
+ "forge.community_join_sheet.join_free": "Kostenlos beitreten",
711
+ "forge.community_join_sheet.joined_title": "Du bist dabei!",
712
+ "forge.community_join_sheet.joining": "Beitritt läuft...",
713
+ "forge.community_join_sheet.last_name": "Nachname",
714
+ "forge.community_join_sheet.mode_signin": "Anmelden",
715
+ "forge.community_join_sheet.mode_signup": "Konto erstellen",
716
+ "forge.community_join_sheet.monthly": "Monatlich",
717
+ "forge.community_join_sheet.monthly_price": "Monatlich, {amount}",
718
+ "forge.community_join_sheet.password": "Passwort",
719
+ "forge.community_join_sheet.pay": "{amount} bezahlen",
720
+ "forge.community_join_sheet.pending_body": "Dein Antrag wird geprüft. Wir melden uns, sobald entschieden ist.",
721
+ "forge.community_join_sheet.pending_title": "Antrag gesendet",
722
+ "forge.community_join_sheet.resend": "Code erneut senden",
723
+ "forge.community_join_sheet.select_placeholder": "Auswählen...",
724
+ "forge.community_join_sheet.sign_in_as": "Als {email} anmelden",
725
+ "forge.community_join_sheet.start_trial": "Kostenlose Testphase starten",
726
+ "forge.community_join_sheet.taking_you_inside": "Wir bringen dich hinein...",
727
+ "forge.community_join_sheet.title": "{community_name} beitreten",
728
+ "forge.community_join_sheet.title_plain": "Beitreten",
729
+ "forge.community_join_sheet.trial_line": "{days} Tage kostenlos testen, erste Abbuchung am {date}",
730
+ "forge.community_join_sheet.verify": "Bestätigen",
731
+ "forge.community_join_sheet.working": "Bitte warten...",
732
+ "forge.community_join_sheet.yearly": "Jährlich",
733
+ "forge.community_join_sheet.yearly_price": "Jährlich, {amount}",
734
+ "forge.community_membership_menu.aria_close": "Schließen",
735
+ "forge.community_membership_menu.cancel": "Mitgliedschaft kündigen",
736
+ "forge.community_membership_menu.cancel_confirm": "Mitgliedschaft kündigen",
737
+ "forge.community_membership_menu.cancel_confirm_now": "Deine Mitgliedschaft endet sofort.",
738
+ "forge.community_membership_menu.cancel_confirm_title": "Mitgliedschaft kündigen?",
739
+ "forge.community_membership_menu.cancel_confirm_until": "Du behältst den Zugang bis {date}. Sie wird nicht verlängert.",
740
+ "forge.community_membership_menu.cancel_keep": "Mitgliedschaft behalten",
741
+ "forge.community_membership_menu.downgrade_to": "Zur Verlängerung zu {tier_name} wechseln",
742
+ "forge.community_membership_menu.ending": "Endet",
743
+ "forge.community_membership_menu.ends_on": "Endet am {date}",
744
+ "forge.community_membership_menu.payment_failed": "Zahlung fehlgeschlagen",
745
+ "forge.community_membership_menu.pending_downgrade": "Wechsel zu {tier_name} am {date}.",
746
+ "forge.community_membership_menu.renews_on": "Verlängert sich am {date}",
747
+ "forge.community_membership_menu.title": "Deine Mitgliedschaft",
748
+ "forge.community_membership_menu.trigger": "Mitgliedschaft",
749
+ "forge.community_membership_menu.undo_downgrade": "Aktuelle Stufe behalten",
750
+ "forge.community_membership_menu.update_card": "Zahlungsmethode aktualisieren",
751
+ "forge.community_membership_menu.upgrade": "Upgrade",
752
+ "forge.community_pending.body": "Dein Antrag wird geprüft. Du erhältst Zugang, sobald er angenommen ist.",
753
+ "forge.community_pending.body_since": "Du hast am {date} beantragt. Du erhältst Zugang, sobald der Antrag angenommen ist.",
754
+ "forge.community_pending.title": "Dein Antrag wird geprüft",
755
+ "forge.community_upgrade_sheet.aria_close": "Schließen",
756
+ "forge.community_upgrade_sheet.back": "Zurück",
757
+ "forge.community_upgrade_sheet.charge_now": "Du zahlst jetzt {amount}.",
758
+ "forge.community_upgrade_sheet.charge_prorated": "Du zahlst jetzt {amount} für den Rest dieses Zeitraums.",
759
+ "forge.community_upgrade_sheet.confirm": "Jetzt upgraden",
760
+ "forge.community_upgrade_sheet.continue_to_card": "Weiter zur Karte",
761
+ "forge.community_upgrade_sheet.current": "Du bist auf {tier_name}.",
762
+ "forge.community_upgrade_sheet.done": "Upgrade abgeschlossen!",
763
+ "forge.community_upgrade_sheet.none": "Du bist bereits auf der höchsten Stufe.",
764
+ "forge.community_upgrade_sheet.pay": "Bezahlen und upgraden",
765
+ "forge.community_upgrade_sheet.price_monthly": "{amount}/Monat",
766
+ "forge.community_upgrade_sheet.price_yearly": "{amount}/Jahr",
767
+ "forge.community_upgrade_sheet.requires_card": "Beim Upgrade gibst du deine Karte erneut ein und dein aktuelles Abo wird ersetzt.",
768
+ "forge.community_upgrade_sheet.title": "Upgrade",
769
+ "forge.community_upgrade_sheet.unlock_with": "Mit {tier_name} freischalten",
658
770
  "forge.confirm_subscription.back_to_site": "Zurück zur Seite",
659
771
  "forge.confirm_subscription.expired_body": "Dieser Bestätigungslink gilt nicht mehr. Melde dich einfach noch einmal an.",
660
772
  "forge.confirm_subscription.expired_title": "Link abgelaufen",
@@ -1167,6 +1279,19 @@
1167
1279
  "forge.login_form.verify_submit": "Bestätigen & anmelden",
1168
1280
  "forge.login_form.verify_title": "Bestätige deine E-Mail",
1169
1281
  "forge.login_form.verifying": "Wird geprüft…",
1282
+ "forge.member_home_layout.nav_label": "Community",
1283
+ "forge.member_home_layout.tab_calendar": "Kalender",
1284
+ "forge.member_home_layout.tab_chat": "Chat",
1285
+ "forge.member_home_layout.tab_collections": "Sammlungen",
1286
+ "forge.member_home_layout.tab_leaderboard": "Bestenliste",
1287
+ "forge.member_home_layout.tab_notifications": "Benachrichtigungen",
1288
+ "forge.member_home_layout.tab_posts": "Beiträge",
1289
+ "forge.member_home_layout.tab_spaces": "Bereiche",
1290
+ "forge.member_post_card.locked_body": "Tritt bei, um diesen Inhalt zu sehen",
1291
+ "forge.member_post_card.locked_body_tier": "Mit {tier_name} freischalten, um diesen Inhalt zu sehen",
1292
+ "forge.member_post_card.locked_title": "Inhalt gesperrt",
1293
+ "forge.member_post_card.unlock": "Freischalten",
1294
+ "forge.member_post_card.unlock_with": "Mit {tier_name} freischalten",
1170
1295
  "forge.membership_checkout.accept_invite": "Einladung annehmen",
1171
1296
  "forge.membership_checkout.amount_below_minimum": "Bitte {minimum} oder mehr eingeben.",
1172
1297
  "forge.membership_checkout.amount_not_positive": "Bitte einen Betrag größer als null eingeben.",
@@ -1203,7 +1328,7 @@
1203
1328
  "forge.membership_checkout.empty_title": "Keine Mitgliedschaften verfügbar",
1204
1329
  "forge.membership_checkout.field_required": "Dieses Feld ist erforderlich",
1205
1330
  "forge.membership_checkout.free": "Kostenlos",
1206
- "forge.membership_checkout.go_to_account": "Zu deinem Konto",
1331
+ "forge.membership_checkout.go_to_community": "Zur Community",
1207
1332
  "forge.membership_checkout.invite_dead_body": "Sie ist vielleicht abgelaufen, wurde schon verwendet oder durch eine neuere Einladung ersetzt. Frag uns nach einer neuen.",
1208
1333
  "forge.membership_checkout.invite_dead_title": "Diese Einladung ist nicht mehr gültig",
1209
1334
  "forge.membership_checkout.invite_email_mismatch": "Du bist als {signed_in_email} angemeldet. Melde dich mit {email} an, um diese Einladung anzunehmen.",
@@ -1235,6 +1360,7 @@
1235
1360
  "forge.membership_tier_callout.invite_for_email": "Diese Einladung gilt für {email}. Melde dich mit dieser Adresse an oder erstelle damit ein Konto.",
1236
1361
  "forge.membership_tier_callout.join_title": "{tier_name} beitreten",
1237
1362
  "forge.membership_tier_callout.more_benefits": "+{count} weitere Vorteile",
1363
+ "forge.membership_tier_callout.view_community": "Teil von {community_name}",
1238
1364
  "forge.membership_tiers.accept_invite": "Einladung annehmen",
1239
1365
  "forge.membership_tiers.application_approved": "Angenommen, wird eingerichtet",
1240
1366
  "forge.membership_tiers.application_declined": "Nicht angenommen",
@@ -1255,6 +1381,14 @@
1255
1381
  "forge.membership_tiers.withdraw": "Bewerbung zurückziehen",
1256
1382
  "forge.membership_tiers.withdraw_error": "Wir konnten deine Bewerbung nicht zurückziehen. Bitte versuche es erneut.",
1257
1383
  "forge.membership_tiers.withdrawing": "Wird zurückgezogen…",
1384
+ "forge.my_memberships_list.cycle_month": "Monat",
1385
+ "forge.my_memberships_list.cycle_year": "Jahr",
1386
+ "forge.my_memberships_list.empty": "Du bist noch in keiner Community Mitglied.",
1387
+ "forge.my_memberships_list.ends_on": "Endet am {date}",
1388
+ "forge.my_memberships_list.open": "Community öffnen",
1389
+ "forge.my_memberships_list.price": "{amount} pro {cycle}",
1390
+ "forge.my_memberships_list.renews_on": "Verlängert sich am {date}",
1391
+ "forge.my_memberships_list.trial_until": "Kostenlose Testphase bis {date}",
1258
1392
  "forge.offer_button.buy_now": "Jetzt kaufen",
1259
1393
  "forge.offer_button.confirm_email": "E-Mail bestätigen",
1260
1394
  "forge.offer_button.continue_to_payment": "Weiter zur Zahlung",
@@ -1310,6 +1444,16 @@
1310
1444
  "forge.podcast_show.empty_title": "Noch keine Folgen.",
1311
1445
  "forge.podcast_show.not_found": "Podcast nicht gefunden.",
1312
1446
  "forge.podcast_show.now_playing": "Läuft gerade",
1447
+ "forge.post_collection_detail.locked_body": "Tritt bei, um diesen Inhalt zu sehen",
1448
+ "forge.post_collection_detail.locked_body_tier": "Mit {tier_name} freischalten, um diesen Inhalt zu sehen",
1449
+ "forge.post_collection_detail.locked_title": "Inhalt gesperrt",
1450
+ "forge.post_collection_detail.unlock": "Freischalten",
1451
+ "forge.post_collection_detail.unlock_with": "Mit {tier_name} freischalten",
1452
+ "forge.post_collections_grid.locked_body": "Tritt bei, um diesen Inhalt zu sehen",
1453
+ "forge.post_collections_grid.locked_body_tier": "Mit {tier_name} freischalten, um diesen Inhalt zu sehen",
1454
+ "forge.post_collections_grid.locked_title": "Inhalt gesperrt",
1455
+ "forge.post_collections_grid.unlock": "Freischalten",
1456
+ "forge.post_collections_grid.unlock_with": "Mit {tier_name} freischalten",
1313
1457
  "forge.posts_feed.empty_body": "Noch keine Beiträge.",
1314
1458
  "forge.presale_code.checking": "Wird geprüft…",
1315
1459
  "forge.presale_code.input_placeholder": "Code eingeben",