@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
@@ -0,0 +1,343 @@
1
+ import { useContext } from "react";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { useForge } from "../../provider/ForgeProvider";
4
+ import { PublicAuthContext } from "../../contexts/PublicAuthContext";
5
+ import type { MembershipTier } from "../../types/models";
6
+
7
+ /**
8
+ * The public communities API (`/public/communities/*` and the fan's own
9
+ * memberships). Every creator can run several communities, each with its own
10
+ * tiers, members, feed, chat and calls, so a community is always NAMED by the
11
+ * caller: by slug in a URL, by id everywhere else.
12
+ *
13
+ * These are the raw data hooks. The step machines and state a page is built
14
+ * from live in `ui/headless/communities`, and the member-content hooks
15
+ * (spaces, feed, chat, calls, collections) read the community from
16
+ * `<CommunityProvider>` rather than taking it as an argument.
17
+ */
18
+
19
+ // ---- Types (mirror docs/proposals/communities-build-contract.md section 3.2) ----
20
+
21
+ /** A community as a visitor sees it. */
22
+ export interface PublicCommunity {
23
+ id: string;
24
+ name: string;
25
+ slug: string;
26
+ description: string | null;
27
+ coverUrl: string | null;
28
+ logoUrl: string | null;
29
+ category: string | null;
30
+ tags: string[];
31
+ memberCount: number;
32
+ }
33
+
34
+ /** A tier the viewer may join: the public tier shape plus the community it belongs to. */
35
+ export type PublicTier = MembershipTier & { communityId: string };
36
+
37
+ /**
38
+ * Where the viewer stands in ONE community. Never read across communities: a
39
+ * member of community A is `none` in community B.
40
+ */
41
+ export type CommunityViewerStateName = "anonymous" | "none" | "pending" | "member" | "past_due" | "cancelled";
42
+
43
+ export interface CommunityViewerMembership {
44
+ id: string;
45
+ tierId: string;
46
+ tierName: string;
47
+ status: string;
48
+ cancelAtPeriodEnd: boolean;
49
+ currentPeriodEnd: string | null;
50
+ /** A downgrade waiting for the end of the paid period, or null. */
51
+ pendingDowngradeTierId: string | null;
52
+ }
53
+
54
+ export interface CommunityViewerApplication {
55
+ id: string;
56
+ tierId: string;
57
+ status: string;
58
+ createdAt: string;
59
+ }
60
+
61
+ export interface CommunityUpgradeTier {
62
+ id: string;
63
+ name: string;
64
+ priceMonthly: number | null;
65
+ priceYearly: number | null;
66
+ }
67
+
68
+ export interface CommunityViewerState {
69
+ state: CommunityViewerStateName;
70
+ membership: CommunityViewerMembership | null;
71
+ application: CommunityViewerApplication | null;
72
+ /** Tiers above the current one, in this community. Empty for a non-member. */
73
+ upgradeTiers: CommunityUpgradeTier[];
74
+ canInteract: boolean;
75
+ }
76
+
77
+ export type CommunityInviteKind = "email" | "link";
78
+ export type CommunityInviteStateName =
79
+ | "valid"
80
+ | "expired"
81
+ | "revoked"
82
+ | "used"
83
+ | "used_up"
84
+ | "email_mismatch"
85
+ | "not_found";
86
+
87
+ export interface CommunityInviteState {
88
+ kind: CommunityInviteKind;
89
+ state: CommunityInviteStateName;
90
+ /** The address a per-email invite was sent to. Null for a shareable link. */
91
+ invitedEmail: string | null;
92
+ /** The tier the invite opens, or null when the token is dead. */
93
+ tier: PublicTier | null;
94
+ }
95
+
96
+ /** `GET /public/communities/:slug` */
97
+ export interface CommunityDetail {
98
+ community: PublicCommunity;
99
+ tiers: PublicTier[];
100
+ viewer: CommunityViewerState;
101
+ invite: CommunityInviteState | null;
102
+ }
103
+
104
+ /** The two invite credentials a URL can carry. Only one is ever set. */
105
+ export interface CommunityTokens {
106
+ /** A per-email invite token (`?invite=`). */
107
+ invite?: string | null;
108
+ /** A shareable invite link token (`?join=`). */
109
+ join?: string | null;
110
+ }
111
+
112
+ export interface CommunityUpgradePreview {
113
+ amount: number;
114
+ currency: string;
115
+ interval: string;
116
+ /** True on Paystack: the upgrade collects the card again and replaces the subscription. */
117
+ requiresCard: boolean;
118
+ prorated: boolean;
119
+ }
120
+
121
+ export interface CommunityUpgradeResult {
122
+ status: "changed" | "requires_payment";
123
+ /** STRIPE: a PaymentIntent secret when the provider needs the card confirmed. */
124
+ clientSecret?: string;
125
+ /** PAYSTACK: the new subscription's inline checkout session. */
126
+ accessCode?: string;
127
+ /** PAYSTACK: hosted page fallback. */
128
+ checkoutUrl?: string;
129
+ membership: CommunityViewerMembership | null;
130
+ }
131
+
132
+ /** One entry of `GET /public/memberships/mine`: a membership in one community. */
133
+ export interface MyCommunityMembership {
134
+ id: string;
135
+ tierId?: string;
136
+ membershipTierId?: string;
137
+ tierName?: string;
138
+ status: string;
139
+ cancelAtPeriodEnd?: boolean;
140
+ currentPeriodEnd?: string | null;
141
+ endDate?: string | null;
142
+ pendingDowngradeTierId?: string | null;
143
+ subscriptionAmount?: number | null;
144
+ subscriptionCurrency?: string | null;
145
+ billingCycle?: string | null;
146
+ trialEndsAt?: string | null;
147
+ membershipTier?: { id: string; name: string } | null;
148
+ community: { id: string; name: string; slug: string };
149
+ }
150
+
151
+ /**
152
+ * The signed-in account, read softly: these hooks also run on a page with no
153
+ * auth provider (a static About page), where the viewer is simply anonymous.
154
+ */
155
+ export function useViewerAccountId(): string | null {
156
+ return useContext(PublicAuthContext)?.user?.id ?? null;
157
+ }
158
+
159
+ // ---- Keys ---------------------------------------------------------------------
160
+
161
+ export const communitiesKeys = {
162
+ all: ["public-communities"] as const,
163
+ list: (profileId?: string) => ["public-communities", "list", profileId] as const,
164
+ detail: (profileId: string | undefined, ref: string | undefined, tokens: CommunityTokens, accountId?: string | null) =>
165
+ ["public-communities", "detail", profileId, ref, tokens.invite ?? null, tokens.join ?? null, accountId ?? null] as const,
166
+ me: (profileId: string | undefined, ref: string | undefined, accountId?: string | null) =>
167
+ ["public-communities", "me", profileId, ref, accountId ?? null] as const,
168
+ invite: (profileId: string | undefined, ref: string | undefined, tokens: CommunityTokens, accountId?: string | null) =>
169
+ ["public-communities", "invite", profileId, ref, tokens.invite ?? null, tokens.join ?? null, accountId ?? null] as const,
170
+ upgradePreview: (profileId: string | undefined, ref: string | undefined, tierId: string | undefined) =>
171
+ ["public-communities", "upgrade-preview", profileId, ref, tierId ?? null] as const,
172
+ mine: (profileId?: string, accountId?: string | null) => ["public-communities", "mine", profileId, accountId ?? null] as const,
173
+ };
174
+
175
+ const tokenParams = (tokens: CommunityTokens) => ({
176
+ ...(tokens.invite ? { invite: tokens.invite } : {}),
177
+ ...(tokens.join ? { join: tokens.join } : {}),
178
+ });
179
+
180
+ const enc = (ref: string) => encodeURIComponent(ref);
181
+
182
+ // ---- Reads ----------------------------------------------------------------------
183
+
184
+ /** Every live community on the profile (`GET /public/communities`). */
185
+ export function usePublicCommunities(opts: { enabled?: boolean } = {}) {
186
+ const { client, profileId } = useForge();
187
+ return useQuery<PublicCommunity[]>({
188
+ queryKey: communitiesKeys.list(profileId),
189
+ queryFn: async () => (await client.get("/public/communities", { params: { profileId } })).data,
190
+ enabled: !!profileId && !!client && opts.enabled !== false,
191
+ });
192
+ }
193
+
194
+ /**
195
+ * One community with its joinable tiers, the viewer's state and, when the URL
196
+ * carried a token, the invite (`GET /public/communities/:slug`). `ref` is a
197
+ * slug or an id. The token is part of the key because it changes which tiers
198
+ * come back: an invite-only tier is listed only with a token that opens it.
199
+ */
200
+ export function usePublicCommunity(ref: string | undefined, tokens: CommunityTokens = {}, opts: { enabled?: boolean } = {}) {
201
+ const { client, profileId } = useForge();
202
+ const accountId = useViewerAccountId();
203
+ return useQuery<CommunityDetail>({
204
+ queryKey: communitiesKeys.detail(profileId, ref, tokens, accountId),
205
+ queryFn: async () =>
206
+ (await client.get(`/public/communities/${enc(ref!)}`, { params: { profileId, ...tokenParams(tokens) } })).data,
207
+ enabled: !!ref && !!profileId && !!client && opts.enabled !== false,
208
+ retry: (count, error) => !isNotFound(error) && count < 2,
209
+ });
210
+ }
211
+
212
+ /** The viewer's state in one community (`GET /public/communities/:slug/me`). Keyed per account. */
213
+ export function useCommunityViewerState(ref: string | undefined, opts: { enabled?: boolean } = {}) {
214
+ const { client, profileId } = useForge();
215
+ const accountId = useViewerAccountId();
216
+ return useQuery<CommunityViewerState>({
217
+ queryKey: communitiesKeys.me(profileId, ref, accountId),
218
+ queryFn: async () => (await client.get(`/public/communities/${enc(ref!)}/me`, { params: { profileId } })).data,
219
+ enabled: !!ref && !!profileId && !!client && opts.enabled !== false,
220
+ });
221
+ }
222
+
223
+ /**
224
+ * What an invite token is worth (`GET /public/communities/:slug/invite`).
225
+ * Keyed per account because `email_mismatch` depends on who is signed in.
226
+ */
227
+ export function useCommunityInviteState(ref: string | undefined, tokens: CommunityTokens) {
228
+ const { client, profileId } = useForge();
229
+ const accountId = useViewerAccountId();
230
+ const hasToken = !!(tokens.invite || tokens.join);
231
+ return useQuery<CommunityInviteState>({
232
+ queryKey: communitiesKeys.invite(profileId, ref, tokens, accountId),
233
+ queryFn: async () =>
234
+ (await client.get(`/public/communities/${enc(ref!)}/invite`, { params: { profileId, ...tokenParams(tokens) } })).data,
235
+ enabled: !!ref && hasToken && !!profileId && !!client,
236
+ retry: (count, error) => !isNotFound(error) && count < 2,
237
+ });
238
+ }
239
+
240
+ /** What moving to `membershipTierId` costs now (`GET /public/communities/:slug/upgrade-preview`). */
241
+ export function useCommunityUpgradePreview(ref: string | undefined, membershipTierId: string | undefined) {
242
+ const { client, profileId } = useForge();
243
+ return useQuery<CommunityUpgradePreview>({
244
+ queryKey: communitiesKeys.upgradePreview(profileId, ref, membershipTierId),
245
+ queryFn: async () =>
246
+ (
247
+ await client.get(`/public/communities/${enc(ref!)}/upgrade-preview`, {
248
+ params: { profileId, membershipTierId },
249
+ })
250
+ ).data,
251
+ enabled: !!ref && !!membershipTierId && !!profileId && !!client,
252
+ });
253
+ }
254
+
255
+ /** Every membership the signed-in fan holds with this creator, one per community. */
256
+ export function useMyCommunityMemberships(opts: { enabled?: boolean } = {}) {
257
+ const { client, profileId } = useForge();
258
+ const accountId = useViewerAccountId();
259
+ return useQuery<MyCommunityMembership[]>({
260
+ queryKey: communitiesKeys.mine(profileId, accountId),
261
+ // The server answers `[{ community, membership }]`; the hooks read one flat
262
+ // row per community, so the envelope is unwrapped here, in one place.
263
+ queryFn: async () => {
264
+ const rows = (await client.get("/public/memberships/mine", { params: { profileId } })).data as Array<{
265
+ community: MyCommunityMembership["community"];
266
+ membership: Record<string, unknown> & { amount?: number | null; currency?: string | null };
267
+ }>;
268
+ return rows.map(
269
+ ({ community, membership }) =>
270
+ ({
271
+ ...membership,
272
+ community,
273
+ subscriptionAmount: membership.amount ?? null,
274
+ subscriptionCurrency: membership.currency ?? null,
275
+ }) as unknown as MyCommunityMembership,
276
+ );
277
+ },
278
+ enabled: !!accountId && !!profileId && !!client && opts.enabled !== false,
279
+ });
280
+ }
281
+
282
+ // ---- Writes -----------------------------------------------------------------------
283
+
284
+ /** Every community read goes stale together after a membership change. */
285
+ export function useInvalidateCommunities() {
286
+ const queryClient = useQueryClient();
287
+ return () =>
288
+ Promise.all([
289
+ queryClient.invalidateQueries({ queryKey: communitiesKeys.all }),
290
+ queryClient.invalidateQueries({ queryKey: ["membership-tiers"] }),
291
+ queryClient.invalidateQueries({ queryKey: ["my-membership-applications"] }),
292
+ ]);
293
+ }
294
+
295
+ /** Move to a higher tier (`POST /public/communities/:slug/upgrade`). */
296
+ export function useUpgradeCommunityTier(ref: string | undefined) {
297
+ const { client, profileId } = useForge();
298
+ const invalidate = useInvalidateCommunities();
299
+ return useMutation<CommunityUpgradeResult, unknown, { membershipTierId: string; returnUrl?: string }>({
300
+ mutationFn: async (body) =>
301
+ (await client.post(`/public/communities/${enc(ref!)}/upgrade`, { profileId, ...body })).data,
302
+ onSuccess: (data) => {
303
+ if (data.status === "changed") void invalidate();
304
+ },
305
+ });
306
+ }
307
+
308
+ /** Cancel (`POST /public/communities/:slug/membership/cancel`). A paid period runs out first. */
309
+ export function useCancelCommunityMembership(ref: string | undefined) {
310
+ const { client, profileId } = useForge();
311
+ const invalidate = useInvalidateCommunities();
312
+ return useMutation<unknown, unknown, void>({
313
+ mutationFn: async () => (await client.post(`/public/communities/${enc(ref!)}/membership/cancel`, { profileId })).data,
314
+ onSuccess: () => void invalidate(),
315
+ });
316
+ }
317
+
318
+ /** Downgrade at the end of the paid period (`POST /public/communities/:slug/membership/downgrade`). */
319
+ export function useDowngradeCommunityMembership(ref: string | undefined) {
320
+ const { client, profileId } = useForge();
321
+ const invalidate = useInvalidateCommunities();
322
+ return useMutation<unknown, unknown, { membershipTierId: string }>({
323
+ mutationFn: async (body) =>
324
+ (await client.post(`/public/communities/${enc(ref!)}/membership/downgrade`, { profileId, ...body })).data,
325
+ onSuccess: () => void invalidate(),
326
+ });
327
+ }
328
+
329
+ /** Withdraw a pending downgrade (`POST /public/communities/:slug/membership/undo-downgrade`). */
330
+ export function useUndoCommunityDowngrade(ref: string | undefined) {
331
+ const { client, profileId } = useForge();
332
+ const invalidate = useInvalidateCommunities();
333
+ return useMutation<unknown, unknown, void>({
334
+ mutationFn: async () =>
335
+ (await client.post(`/public/communities/${enc(ref!)}/membership/undo-downgrade`, { profileId })).data,
336
+ onSuccess: () => void invalidate(),
337
+ });
338
+ }
339
+
340
+ /** True when an error is a 404: an answer (no such community, dead token), not a transient failure. */
341
+ export function isNotFound(error: unknown): boolean {
342
+ return (error as { response?: { status?: number } } | null)?.response?.status === 404;
343
+ }