@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,7 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
+ import { useOptionalCommunityContext } from "../../../contexts/CommunityContext";
4
+ import { communityHomePath, communitiesListPath } from "../communities/paths";
3
5
  import {
4
6
  readMembershipInviteFromUrl,
5
7
  useApplyForMembership,
@@ -55,9 +57,13 @@ export type { BillingCycle };
55
57
  export interface UseMembershipCheckoutOptions {
56
58
  /** Pre-select a tier (e.g. from a `?membershipTierId=` param). */
57
59
  initialTierId?: string;
58
- /** Where the provider returns the member after a paid subscription. Default `/i/account?tab=membership&confirmSubscription=true`. */
60
+ /**
61
+ * Where the provider returns the member after a paid subscription. Default:
62
+ * the community home with `confirmSubscription=1` (the community comes from
63
+ * `<CommunityProvider>`), or `/i/communities` outside one. Never the account page.
64
+ */
59
65
  returnPath?: string;
60
- /** Where free activations land. Default `/i/account?tab=membership`. */
66
+ /** Where free activations land. Default: the community home with `welcome=1`. */
61
67
  freeReturnPath?: string;
62
68
  /**
63
69
  * Called when the subscription completes IN-APP with nothing left to pay: a
@@ -73,13 +79,30 @@ export interface UseMembershipCheckoutOptions {
73
79
  * the current url is used; `null` sends none.
74
80
  */
75
81
  inviteToken?: string | null;
82
+ /**
83
+ * A shareable invite link token (`?join=`), sent with every join call. It
84
+ * opens an invite-only tier; a paid tier still charges.
85
+ */
86
+ joinToken?: string | null;
76
87
  /**
77
88
  * Where Stripe returns after a redirect-based card confirmation on an
78
- * APPLICATION (a card that needed a bank redirect). Default
79
- * `/i/account?tab=membership`, where the fan's applications are listed. The
80
- * application id is appended as `confirmApplication`.
89
+ * APPLICATION (a card that needed a bank redirect). Default: the community
90
+ * home, where a pending application is shown. The application id is appended
91
+ * as `confirmApplication`.
81
92
  */
82
93
  applicationReturnPath?: string;
94
+ /**
95
+ * The tiers to offer, when the caller already has them (the community read
96
+ * returns exactly the tiers this viewer may join). Omitted, they are fetched.
97
+ */
98
+ tiers?: MembershipTier[];
99
+ /**
100
+ * The viewer's current membership IN THIS COMMUNITY, when the caller knows it.
101
+ * Decides whether subscribing is a tier change. Omitted, the signed-in
102
+ * account's single legacy `membership` is read, which knows nothing of
103
+ * communities, so a community surface always passes this.
104
+ */
105
+ current?: { tierId: string; status: string } | null;
83
106
  }
84
107
 
85
108
  const errMessage = (e: unknown) =>
@@ -106,8 +129,17 @@ const errMessage = (e: unknown) =>
106
129
  */
107
130
  export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
108
131
  const { user, refetchUser } = usePublicAuth();
132
+ const community = useOptionalCommunityContext();
109
133
  const inviteToken = opts.inviteToken === undefined ? readMembershipInviteFromUrl() : (opts.inviteToken ?? undefined);
110
- const { data: tiers, isLoading } = useGetMembershipTiers({ inviteToken: inviteToken ?? null });
134
+ const joinToken = opts.joinToken ?? undefined;
135
+ const fetched = useGetMembershipTiers({ inviteToken: inviteToken ?? null, enabled: !opts.tiers });
136
+ const tiers = opts.tiers ?? fetched.data;
137
+ const isLoading = opts.tiers ? false : fetched.isLoading;
138
+ /** The landing paths, inside the community whenever there is one. */
139
+ const homeFor = (query?: { welcome?: 1; confirmSubscription?: 1 }) =>
140
+ community?.slug ? communityHomePath(community.slug, query) : communitiesListPath();
141
+ const paidReturnPath = opts.returnPath ?? homeFor({ confirmSubscription: 1 });
142
+ const freeReturnPath = opts.freeReturnPath ?? homeFor({ welcome: 1 });
111
143
  const createPaid = useCreateSubscription();
112
144
  const createFree = useCreateFreeSubscription();
113
145
  const confirmLatest = useConfirmLatestSubscription();
@@ -224,16 +256,29 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
224
256
  [selectedTier, billingCycle, customAmount],
225
257
  );
226
258
 
259
+ /**
260
+ * The membership this checkout acts on. A fan holds one membership PER
261
+ * community, so inside a `CommunityProvider` only the caller's `current` (the
262
+ * viewer's membership in THAT community, as `useCommunityJoin` and the styled
263
+ * `MembershipCheckout` pass it) may name one. Without it the fan is treated as
264
+ * holding none here: `user.membership` is the newest membership on the whole
265
+ * profile and may belong to another community, where it would turn this join
266
+ * into a tier change. The server decides per community either way.
267
+ * `user.membership` remains the fallback only outside any community.
268
+ */
269
+ const current = opts.current !== undefined ? opts.current : community?.communityId ? null : undefined;
227
270
  const membership = user?.membership;
228
271
  /** The tier they are on now, so its card can say so instead of offering itself. */
229
- const currentTierId = membership?.membershipTierId;
272
+ const currentTierId = current !== undefined ? (current?.tierId ?? undefined) : membership?.membershipTierId;
230
273
  /**
231
274
  * A member with a live provider subscription picking a PAID tier is changing,
232
275
  * not joining. The server settles it by proration on the subscription they
233
276
  * already have, so the button says so.
234
277
  */
235
278
  const isChange =
236
- !!membership?.paymentProviderSubscriptionId && membership.status === "active" && !isFreeCycle && !!selectedTier;
279
+ current !== undefined
280
+ ? !!current && current.status === "active" && !isFreeCycle && !!selectedTier
281
+ : !!membership?.paymentProviderSubscriptionId && membership.status === "active" && !isFreeCycle && !!selectedTier;
237
282
 
238
283
  /**
239
284
  * The free trial this fan would start on the selected tier, in days, or 0.
@@ -264,9 +309,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
264
309
  }
265
310
  if (typeof window !== "undefined") {
266
311
  const origin = window.location.origin;
267
- const path = requiresConfirmation
268
- ? (opts.returnPath ?? "/i/account?tab=membership&confirmSubscription=true")
269
- : (opts.freeReturnPath ?? "/i/account?tab=membership");
312
+ const path = requiresConfirmation ? paidReturnPath : freeReturnPath;
270
313
  window.location.href = `${origin}${path}`;
271
314
  }
272
315
  };
@@ -275,7 +318,26 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
275
318
  const questionnaireBody = () =>
276
319
  questionnaire.length ? { questionnaire: buildQuestionnaireAnswers(questionnaire, questionnaireAnswers) } : {};
277
320
 
278
- const subscribe = async () => {
321
+ /**
322
+ * How many join calls are running. `isProcessing` reads THIS, not the
323
+ * mutations' `isPending`: `subscribe` is started from an effect by the join
324
+ * sheet, and a mutation started from an effect loses its observer when React
325
+ * StrictMode unmounts and remounts in development, leaving `isPending` stuck
326
+ * true after the request answered. A settled promise always clears this.
327
+ */
328
+ const [inFlight, setInFlight] = useState(0);
329
+ const tracked =
330
+ <A extends unknown[]>(run: (...args: A) => Promise<void>) =>
331
+ async (...args: A) => {
332
+ setInFlight((n) => n + 1);
333
+ try {
334
+ await run(...args);
335
+ } finally {
336
+ setInFlight((n) => n - 1);
337
+ }
338
+ };
339
+
340
+ const subscribe = tracked(async () => {
279
341
  setError(null);
280
342
  setAmountRefusal(null);
281
343
  setTrial(null);
@@ -302,10 +364,10 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
302
364
  await submitApplication(origin);
303
365
  return;
304
366
  }
305
- const returnUrl = `${origin}${opts.returnPath ?? "/i/account?tab=membership&confirmSubscription=true"}`;
367
+ const returnUrl = `${origin}${paidReturnPath}`;
306
368
  const attributionRefId = readAttributionRef() ?? undefined;
307
369
  const landing = readLanding() ?? {};
308
- const invite = inviteToken ? { inviteToken } : {};
370
+ const invite = { ...(inviteToken ? { inviteToken } : {}), ...(joinToken ? { joinToken } : {}) };
309
371
  const answers = questionnaireBody();
310
372
  try {
311
373
  if (isFreeCycle) {
@@ -371,7 +433,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
371
433
  } catch (e) {
372
434
  setError(errMessage(e));
373
435
  }
374
- };
436
+ });
375
437
 
376
438
  /**
377
439
  * Apply to an `application` tier. Free: the application is lodged and the
@@ -379,14 +441,16 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
379
441
  * moves to "payment" and the renderer saves the card; `confirmApplication()`
380
442
  * runs after it succeeds. Nothing is charged until the business approves.
381
443
  */
382
- const applicationReturnBase = opts.applicationReturnPath ?? "/i/account?tab=membership";
383
- const submitApplication = async (origin: string) => {
444
+ const applicationReturnBase = opts.applicationReturnPath ?? homeFor();
445
+ const submitApplication = tracked(async (origin: string) => {
384
446
  if (!selectedTier) return;
385
447
  try {
386
448
  const data = await applyMutation.mutateAsync({
387
449
  membershipTierId: selectedTier.id,
388
450
  ...(isFreeCycle ? {} : { billingCycle, amount }),
389
451
  ...questionnaireBody(),
452
+ ...(inviteToken ? { inviteToken } : {}),
453
+ ...(joinToken ? { joinToken } : {}),
390
454
  returnUrl: `${origin}${applicationReturnBase}`,
391
455
  });
392
456
  setApplication(data.application);
@@ -398,10 +462,10 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
398
462
  } catch (e) {
399
463
  setError(errMessage(e));
400
464
  }
401
- };
465
+ });
402
466
 
403
467
  /** Call after the card renderer confirmed the SetupIntent. Idempotent on the server. */
404
- const confirmApplication = async () => {
468
+ const confirmApplication = tracked(async () => {
405
469
  if (!application) return;
406
470
  setError(null);
407
471
  setCardConfirmed(true);
@@ -411,7 +475,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
411
475
  } catch (e) {
412
476
  setError(errMessage(e));
413
477
  }
414
- };
478
+ });
415
479
 
416
480
  const applicationOutcome: MembershipApplicationOutcome | null = membershipApplicationOutcome(
417
481
  application,
@@ -478,8 +542,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
478
542
  onError: (message) => setError(message ?? "The payment could not be completed. Please try again."),
479
543
  })
480
544
  : Promise.resolve("unavailable" as PaystackCheckoutOutcome),
481
- isProcessing:
482
- createPaid.isPending || createFree.isPending || applyMutation.isPending || confirmApplicationMutation.isPending,
545
+ isProcessing: inFlight > 0,
483
546
  error,
484
547
  /** Call on the return page to reconcile the latest subscription. */
485
548
  confirmLatest,
@@ -487,6 +550,10 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
487
550
  finish,
488
551
  /** The invite token this checkout forwards, if any. */
489
552
  inviteToken,
553
+ /** The shareable link token this checkout forwards, if any. */
554
+ joinToken,
555
+ /** Where a paid subscription returns: the community home with `confirmSubscription=1` by default. */
556
+ returnPath: paidReturnPath,
490
557
  /** True when subscribe() applies to the tier instead of joining it. */
491
558
  isApplication,
492
559
  /**
@@ -1,6 +1,8 @@
1
1
  import { useMemo } from "react";
2
2
  import type { PublicMembershipGate } from "../../../types/models";
3
3
  import { useGetMembershipTiers } from "../../../data/queries/useMembership";
4
+ import { usePublicCommunities } from "../../../data/queries/useCommunities";
5
+ import { communityAboutPath } from "../communities/paths";
4
6
  import {
5
7
  buildMembershipGateNotice,
6
8
  isMembershipGateLocked,
@@ -13,11 +15,15 @@ export interface UseMembershipGateNoticeInput {
13
15
  gate?: PublicMembershipGate | null;
14
16
  /** A `MEMBERSHIP_GATE` refusal from a failed purchase. Wins over `gate`. */
15
17
  refusal?: MembershipGateRefusal | null;
16
- /** What the thing is, in the buyer's words — "This ticket", "This course". */
18
+ /** What the thing is, in the buyer's words: "This ticket", "This course". */
17
19
  itemLabel?: string;
18
20
  /** Where a signed-out visitor signs in. Code sites mount theirs under `/i`. */
19
21
  loginPath?: string;
20
- /** Where a signed-in non-member goes to join — the tier listing. */
22
+ /**
23
+ * Where a signed-in non-member goes to join. Default: the About page of the
24
+ * community the first required tier belongs to, with that tier preselected
25
+ * (`/i/communities/<slug>?tier=<id>`), which joins in place.
26
+ */
21
27
  membershipPath?: string;
22
28
  }
23
29
 
@@ -29,14 +35,14 @@ export interface UseMembershipGateNoticeInput {
29
35
  * A gate comes down as `requiredTierIds`, and "you need tier 8f3c-…" is not a
30
36
  * sentence anyone can act on. The public tier list is already loaded by every
31
37
  * storefront that sells memberships, is cached by React Query under one key per
32
- * profile, and — unlike an id embedded in a ticket payload — stays correct when
38
+ * profile, and (unlike an id embedded in a ticket payload) stays correct when
33
39
  * the artist renames a tier. So the ids are resolved against it here, once, for
34
40
  * every surface.
35
41
  *
36
42
  * The names are best-effort by design: the query can be in flight, and a gating
37
43
  * tier can be one the public list does not carry (an archived or unlisted tier
38
44
  * still gates). `buildMembershipGateNotice` therefore degrades to "is for
39
- * members" rather than showing an id or rendering nothing — a badge with slightly
45
+ * members" rather than showing an id or rendering nothing: a badge with slightly
40
46
  * vaguer wording is a working funnel; a blank space is the bug this closes.
41
47
  *
42
48
  * Returns `null` when nothing is gated, which is also what every surface sees
@@ -44,22 +50,33 @@ export interface UseMembershipGateNoticeInput {
44
50
  */
45
51
  export function useMembershipGateNotice(input: UseMembershipGateNoticeInput): MembershipGateNotice | null {
46
52
  const locked = isMembershipGateLocked(input.gate) || !!input.refusal;
47
- // Only pulled once something is actually locked — an ungated storefront must
53
+ // Only pulled once something is actually locked: an ungated storefront must
48
54
  // not acquire a request it never needed.
49
- const { data: tiers } = useGetMembershipTiers({ enabled: locked });
55
+ // Profile-wide on purpose: a gate on a product can name a tier from any community.
56
+ const { data: tiers } = useGetMembershipTiers({ enabled: locked, communityId: null });
57
+ const { data: communities } = usePublicCommunities({ enabled: locked && !input.membershipPath });
50
58
 
51
59
  const requiredTierIds = input.refusal?.requiredTierIds ?? input.gate?.requiredTierIds ?? [];
52
60
 
53
61
  return useMemo(() => {
54
62
  if (!locked) return null;
55
63
  const byId = new Map((tiers ?? []).map((tier) => [tier.id, tier.name]));
64
+ // A tier belongs to exactly one community: its About page is where to join it.
65
+ const firstTier = (tiers ?? []).find((tier) => tier.id === requiredTierIds[0]) as
66
+ | { id: string; communityId?: string }
67
+ | undefined;
68
+ const community = firstTier?.communityId
69
+ ? communities?.find((candidate) => candidate.id === firstTier.communityId)
70
+ : undefined;
71
+ const membershipPath =
72
+ input.membershipPath ?? (community ? communityAboutPath(community.slug, { tier: firstTier!.id }) : undefined);
56
73
  return buildMembershipGateNotice({
57
74
  gate: input.gate,
58
75
  refusal: input.refusal,
59
76
  tierNames: requiredTierIds.map((id) => byId.get(id) ?? "").filter(Boolean),
60
77
  itemLabel: input.itemLabel,
61
78
  loginPath: input.loginPath,
62
- membershipPath: input.membershipPath,
79
+ membershipPath,
63
80
  currentPath: currentPath(),
64
81
  });
65
82
  // `requiredTierIds` is a fresh array each render; its CONTENTS are the input.
@@ -67,6 +84,7 @@ export function useMembershipGateNotice(input: UseMembershipGateNoticeInput): Me
67
84
  }, [
68
85
  locked,
69
86
  tiers,
87
+ communities,
70
88
  requiredTierIds.join(","),
71
89
  input.gate?.reason,
72
90
  input.refusal?.reason,
@@ -1,4 +1,6 @@
1
1
  import type { IPublicPost, MembershipTier } from "../../../types/models";
2
+ import { useOptionalCommunityContext, useCommunityUpgradeOpener } from "../../../contexts/CommunityContext";
3
+ import { communityAboutPath } from "../communities/paths";
2
4
 
3
5
  /** Where a locked post/collection sends the member to unlock it. */
4
6
  export interface PostUpsell {
@@ -6,36 +8,80 @@ export interface PostUpsell {
6
8
  tierId: string | null;
7
9
  /** Resolved tier (name/price) when the tiers list is available. */
8
10
  tier: MembershipTier | null;
9
- /** Membership checkout deep link, e.g. `/membership/checkout?membershipTierId=…`. */
11
+ /**
12
+ * The join link: the community's About page with the tier preselected
13
+ * (`/i/communities/<slug>?tier=<id>`) inside a community, else `checkoutPath`.
14
+ */
10
15
  checkoutHref: string;
11
- /** Signup-first link for anonymous visitors (redirects into checkout after signup). */
16
+ /** Signup-first link for anonymous visitors (redirects to `checkoutHref` after signup). */
12
17
  signupHref: string;
13
18
  /** The link to actually use: checkout when authenticated, signup otherwise. */
14
19
  href: string;
20
+ /**
21
+ * Opens the community's upgrade sheet on this tier ("Unlock with {tier}")
22
+ * when a community home provides one and the viewer is signed in. Null
23
+ * otherwise: follow `href`.
24
+ */
25
+ unlock: (() => void) | null;
15
26
  }
16
27
 
17
28
  export interface UpsellPathOptions {
18
- /** Membership checkout path (default `/membership/checkout`; site-starter uses `/i/membership/checkout`). */
29
+ /**
30
+ * Where a locked item's CTA leads outside a community. Inside one it is the
31
+ * community's About page, which joins in place. Default `/i/communities`.
32
+ */
19
33
  checkoutPath?: string;
20
34
  /** Signup path (default `/signup`; site-starter uses `/i/signup`). */
21
35
  signupPath?: string;
36
+ /** The community's slug. Filled from `<CommunityProvider>` by the hooks. */
37
+ communitySlug?: string;
22
38
  }
23
39
 
24
- export const DEFAULT_CHECKOUT_PATH = "/membership/checkout";
40
+ export const DEFAULT_CHECKOUT_PATH = "/i/communities";
25
41
  export const DEFAULT_SIGNUP_PATH = "/signup";
26
42
 
27
- /** Build the locked-content upsell state for a post. Pure — shared by the feed + item hooks. */
43
+ /** Build the locked-content upsell state for a post. Pure: shared by the feed + item hooks. */
28
44
  export function buildPostUpsell(
29
45
  post: Pick<IPublicPost, "membershipTiers">,
30
46
  tiers: MembershipTier[] | undefined,
31
47
  isAuthenticated: boolean,
32
48
  opts: UpsellPathOptions = {},
49
+ unlockWith?: ((tierId: string | null) => void) | null,
33
50
  ): PostUpsell {
34
- const checkoutPath = opts.checkoutPath ?? DEFAULT_CHECKOUT_PATH;
35
51
  const signupPath = opts.signupPath ?? DEFAULT_SIGNUP_PATH;
36
52
  const tierId = post.membershipTiers?.[0] ?? null;
37
53
  const tier = (tierId && tiers?.find((t) => t.id === tierId)) || null;
38
- const checkoutHref = tierId ? `${checkoutPath}?membershipTierId=${tierId}` : checkoutPath;
54
+ let checkoutHref: string;
55
+ if (opts.communitySlug) {
56
+ checkoutHref = communityAboutPath(opts.communitySlug, tierId ? { tier: tierId } : undefined);
57
+ } else {
58
+ const checkoutPath = opts.checkoutPath ?? DEFAULT_CHECKOUT_PATH;
59
+ checkoutHref = tierId ? `${checkoutPath}${checkoutPath.includes("?") ? "&" : "?"}tier=${tierId}` : checkoutPath;
60
+ }
39
61
  const signupHref = `${signupPath}?redirect=${encodeURIComponent(checkoutHref)}`;
40
- return { tierId, tier, checkoutHref, signupHref, href: isAuthenticated ? checkoutHref : signupHref };
62
+ return {
63
+ tierId,
64
+ tier,
65
+ checkoutHref,
66
+ signupHref,
67
+ href: isAuthenticated ? checkoutHref : signupHref,
68
+ unlock: unlockWith && isAuthenticated ? () => unlockWith(tierId) : null,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * The upsell inputs a hook reads from the surrounding community: its slug (for
74
+ * the About page link) and the upgrade sheet opener, when a community home
75
+ * provides one. Explicit options win.
76
+ */
77
+ export function useUpsellContext(opts: UpsellPathOptions = {}): {
78
+ paths: UpsellPathOptions;
79
+ unlockWith: ((tierId: string | null) => void) | null;
80
+ } {
81
+ const community = useOptionalCommunityContext();
82
+ const opener = useCommunityUpgradeOpener();
83
+ return {
84
+ paths: { ...opts, communitySlug: opts.communitySlug ?? community?.slug },
85
+ unlockWith: opener ? (tierId) => opener.openUpgrade(tierId) : null,
86
+ };
41
87
  }
@@ -3,7 +3,7 @@ import type { IPublicPost, MembershipTier, PostType } from "../../../types/model
3
3
  import { useGetPostsInfinite, useGetSavedPosts } from "../../../data/queries/usePosts";
4
4
  import { useGetMembershipTiers } from "../../../data/queries/useMembership";
5
5
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
6
- import { buildPostUpsell, type PostUpsell, type UpsellPathOptions } from "./upsell";
6
+ import { buildPostUpsell, useUpsellContext, type PostUpsell, type UpsellPathOptions } from "./upsell";
7
7
 
8
8
  export interface UseMemberPostsFeedOptions extends UpsellPathOptions {
9
9
  /** Posts per page (default 10). */
@@ -13,16 +13,17 @@ export interface UseMemberPostsFeedOptions extends UpsellPathOptions {
13
13
  }
14
14
 
15
15
  /**
16
- * Headless member posts feed — the Forge port of the craft members area's
16
+ * Headless member posts feed: the Forge port of the craft members area's
17
17
  * `PostsContent` semantics: gated infinite paging over `GET /public/posts`
18
18
  * (the server computes per-post `hasAccess` and ships no media for locked
19
- * posts — never re-gate client-side), debounced search, post-type filter,
19
+ * posts: never re-gate client-side), debounced search, post-type filter,
20
20
  * membership-tier filter, a saved-posts view (`GET /public/posts/saved`),
21
21
  * and the locked-post upsell target (which tier unlocks + membership
22
22
  * checkout deep link). Bring your own UI.
23
23
  */
24
24
  export function useMemberPostsFeed(opts: UseMemberPostsFeedOptions = {}) {
25
25
  const { isAuthenticated } = usePublicAuth();
26
+ const upsellContext = useUpsellContext(opts);
26
27
 
27
28
  // ── Filters ────────────────────────────────────────────────────────────────
28
29
  const [search, setSearchState] = useState("");
@@ -70,14 +71,14 @@ export function useMemberPostsFeed(opts: UseMemberPostsFeedOptions = {}) {
70
71
  );
71
72
  const total = savedOnly ? (saved.data?.total ?? 0) : (feed.data?.pages[0]?.total ?? 0);
72
73
 
73
- /** Accessible audio posts in feed order — the play queue for audio playback. */
74
+ /** Accessible audio posts in feed order: the play queue for audio playback. */
74
75
  const audioPosts = useMemo(
75
76
  () => posts.filter((p) => p.type === "audio" && p.hasAccess && p.media?.[0]?.url),
76
77
  [posts],
77
78
  );
78
79
 
79
80
  /** Locked-post upsell: which tier unlocks it + the checkout/signup deep link. */
80
- const getUpsell = (post: IPublicPost): PostUpsell => buildPostUpsell(post, tiers, isAuthenticated, opts);
81
+ const getUpsell = (post: IPublicPost): PostUpsell => buildPostUpsell(post, tiers, isAuthenticated, upsellContext.paths, upsellContext.unlockWith);
81
82
 
82
83
  return {
83
84
  posts,
@@ -3,12 +3,12 @@ import type { IPublicPost } from "../../../types/models";
3
3
  import { useGetPostCollection } from "../../../data/queries/usePostCollections";
4
4
  import { useGetMembershipTiers } from "../../../data/queries/useMembership";
5
5
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
6
- import { buildPostUpsell, type PostUpsell, type UpsellPathOptions } from "./upsell";
6
+ import { buildPostUpsell, useUpsellContext, type PostUpsell, type UpsellPathOptions } from "./upsell";
7
7
 
8
8
  export type UsePostCollectionDetailOptions = UpsellPathOptions;
9
9
 
10
10
  /**
11
- * Headless single-collection drill-in — the Forge port of the craft members
11
+ * Headless single-collection drill-in: the Forge port of the craft members
12
12
  * area's `CollectionsContent` detail semantics over
13
13
  * `GET /public/post-collections/:id`. The server gates per-collection AND
14
14
  * per-post (`hasAccess`; locked posts ship no media), so a member who can see
@@ -19,6 +19,7 @@ export type UsePostCollectionDetailOptions = UpsellPathOptions;
19
19
  */
20
20
  export function usePostCollectionDetail(collectionId: string | undefined, opts: UsePostCollectionDetailOptions = {}) {
21
21
  const { isAuthenticated } = usePublicAuth();
22
+ const upsellContext = useUpsellContext(opts);
22
23
  const { data: tiers } = useGetMembershipTiers();
23
24
  const detailQ = useGetPostCollection(collectionId);
24
25
 
@@ -30,7 +31,7 @@ export function usePostCollectionDetail(collectionId: string | undefined, opts:
30
31
  [collection],
31
32
  );
32
33
 
33
- /** Accessible audio posts in collection order — the play queue for audio playback. */
34
+ /** Accessible audio posts in collection order: the play queue for audio playback. */
34
35
  const audioPosts = useMemo(
35
36
  () => posts.filter((p) => p.type === "audio" && p.hasAccess && p.media?.[0]?.url),
36
37
  [posts],
@@ -40,7 +41,7 @@ export function usePostCollectionDetail(collectionId: string | undefined, opts:
40
41
  const cover = collection?.media?.[0] ?? null;
41
42
 
42
43
  /** Locked-collection upsell (null until the collection loads). */
43
- const upsell: PostUpsell | null = collection ? buildPostUpsell(collection, tiers, isAuthenticated, opts) : null;
44
+ const upsell: PostUpsell | null = collection ? buildPostUpsell(collection, tiers, isAuthenticated, upsellContext.paths, upsellContext.unlockWith) : null;
44
45
 
45
46
  return {
46
47
  collection,
@@ -51,6 +52,8 @@ export function usePostCollectionDetail(collectionId: string | undefined, opts:
51
52
  upsell,
52
53
  isLoading: detailQ.isLoading,
53
54
  isError: detailQ.isError,
55
+ /** The collection does not exist in this community (or is archived). */
56
+ notFound: detailQ.isSuccess && detailQ.data === null,
54
57
  isAuthenticated,
55
58
  };
56
59
  }
@@ -3,7 +3,7 @@ import type { IPublicPostCollection, MembershipTier } from "../../../types/model
3
3
  import { useGetPostCollections } from "../../../data/queries/usePostCollections";
4
4
  import { useGetMembershipTiers } from "../../../data/queries/useMembership";
5
5
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
6
- import { buildPostUpsell, type PostUpsell, type UpsellPathOptions } from "./upsell";
6
+ import { buildPostUpsell, useUpsellContext, type PostUpsell, type UpsellPathOptions } from "./upsell";
7
7
 
8
8
  export interface UsePostCollectionsListOptions extends UpsellPathOptions {
9
9
  /** Debounce for the search input → query round trip (default 500ms, matching the craft members area). */
@@ -11,15 +11,16 @@ export interface UsePostCollectionsListOptions extends UpsellPathOptions {
11
11
  }
12
12
 
13
13
  /**
14
- * Headless post-collections list — the Forge port of the craft members area's
14
+ * Headless post-collections list: the Forge port of the craft members area's
15
15
  * `CollectionsContent` list semantics: paginated `GET /public/post-collections`
16
16
  * (the server computes per-collection `hasAccess`; a locked collection can be
17
- * shown but not opened — never re-gate client-side), debounced search, and the
17
+ * shown but not opened: never re-gate client-side), debounced search, and the
18
18
  * locked-collection upsell target (which tier unlocks + membership checkout
19
19
  * deep link). Bring your own UI; drill-in uses `usePostCollectionDetail`.
20
20
  */
21
21
  export function usePostCollectionsList(opts: UsePostCollectionsListOptions = {}) {
22
22
  const { isAuthenticated } = usePublicAuth();
23
+ const upsellContext = useUpsellContext(opts);
23
24
 
24
25
  // ── Search + paging ────────────────────────────────────────────────────────
25
26
  const [search, setSearchState] = useState("");
@@ -61,14 +62,14 @@ export function usePostCollectionsList(opts: UsePostCollectionsListOptions = {})
61
62
 
62
63
  /** Locked-collection upsell: which tier unlocks it + the checkout/signup deep link. */
63
64
  const getUpsell = (collection: IPublicPostCollection): PostUpsell =>
64
- buildPostUpsell(collection, tiers, isAuthenticated, opts);
65
+ buildPostUpsell(collection, tiers, isAuthenticated, upsellContext.paths, upsellContext.unlockWith);
65
66
 
66
67
  return {
67
68
  collections,
68
69
  total,
69
70
  isLoading: listQ.isLoading,
70
71
  isError: listQ.isError,
71
- // Paging (numbered, server-side — matches the craft pagination).
72
+ // Paging (numbered, server-side: matches the craft pagination).
72
73
  page,
73
74
  pageSize,
74
75
  totalPages,
@@ -4,10 +4,10 @@ import type { AudioTrack } from "../../../contexts/AudioPlayerContext";
4
4
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
5
5
  import { useGetMembershipTiers } from "../../../data/queries/useMembership";
6
6
  import { usePostEngagement } from "../engagement/usePostEngagement";
7
- import { buildPostUpsell, type PostUpsell, type UpsellPathOptions } from "./upsell";
7
+ import { buildPostUpsell, useUpsellContext, type PostUpsell, type UpsellPathOptions } from "./upsell";
8
8
 
9
9
  export interface UsePostItemOptions extends UpsellPathOptions {
10
- /** Other audio posts in the feed — becomes the continue-playing queue. */
10
+ /** Other audio posts in the feed: becomes the continue-playing queue. */
11
11
  otherAudioPosts?: IPublicPost[];
12
12
  /** Shown as the audio track artist (the craft members area uses the profile name). */
13
13
  artistName?: string;
@@ -28,7 +28,7 @@ const toTrack = (post: IPublicPost, media: IMedia, artistName?: string): AudioTr
28
28
  });
29
29
 
30
30
  /**
31
- * Headless single-post behavior — the Forge port of the craft `PostItem` minus
31
+ * Headless single-post behavior: the Forge port of the craft `PostItem` minus
32
32
  * rendering: engagement (like/save/comment via `usePostEngagement`), media
33
33
  * selection (image/video/audio/document), the audio track + queue for the
34
34
  * Forge audio player, and the locked-post upsell state. The server already
@@ -36,6 +36,7 @@ const toTrack = (post: IPublicPost, media: IMedia, artistName?: string): AudioTr
36
36
  */
37
37
  export function usePostItem(post: IPublicPost, opts: UsePostItemOptions = {}) {
38
38
  const { isAuthenticated } = usePublicAuth();
39
+ const upsellContext = useUpsellContext(opts);
39
40
  const { data: tiers } = useGetMembershipTiers();
40
41
 
41
42
  const engagement = usePostEngagement(post.id, "post", {
@@ -64,13 +65,13 @@ export function usePostItem(post: IPublicPost, opts: UsePostItemOptions = {}) {
64
65
  [opts.otherAudioPosts, opts.artistName],
65
66
  );
66
67
 
67
- const upsell: PostUpsell = buildPostUpsell(post, tiers, isAuthenticated, opts);
68
+ const upsell: PostUpsell = buildPostUpsell(post, tiers, isAuthenticated, upsellContext.paths, upsellContext.unlockWith);
68
69
 
69
70
  return {
70
71
  post,
71
72
  kind,
72
73
  isLocked,
73
- /** First media item (null when locked — the server ships none). */
74
+ /** First media item (null when locked: the server ships none). */
74
75
  media,
75
76
  audioTrack,
76
77
  audioQueue,