@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
@@ -16,6 +16,9 @@ import { formatTrialDate, tierTrialDays } from "../headless/membership/membershi
16
16
  import { readableTextOn } from "../theme/contrast";
17
17
  import { cycleFloor, cycleIsFree, offeredCycles, type BillingCycle } from "../format/membershipPwyw";
18
18
  import type { MembershipTier } from "../../types/models";
19
+ import { useOptionalCommunityContext } from "../../contexts/CommunityContext";
20
+ import { useCommunityViewerState } from "../../data/queries/useCommunities";
21
+ import { communitiesListPath, communityAboutPath, communityHomePath } from "../headless/communities/paths";
19
22
  import { Loading } from "./Loading";
20
23
  import { PaystackPayButton } from "./PaystackPayButton";
21
24
 
@@ -25,23 +28,24 @@ export interface MembershipCheckoutProps {
25
28
  /**
26
29
  * Called when the subscription is finished in-app: a free activation, a tier
27
30
  * change settled by proration, or a paid card payment that succeeded. The
28
- * host navigates however it wants (e.g. to the account page). Omit to fall
29
- * back to Forge's default `window.location` redirect.
31
+ * host navigates however it wants. Omit to fall back to Forge's default
32
+ * `window.location` redirect, which lands inside the community.
30
33
  *
31
34
  * `requiresConfirmation` is true only for a real provider payment, which is
32
35
  * the only case the account page has anything to reconcile.
33
36
  */
34
37
  onComplete?: (result: { requiresConfirmation: boolean }) => void;
35
38
  /** The URL Stripe returns to for a PAID subscription (its `return_url`, and the
36
- * free/paid fallback redirect). Default `/i/account?tab=membership&confirmSubscription=true`. */
39
+ * free/paid fallback redirect). Default: the community home with
40
+ * `confirmSubscription=1` (the community comes from `<CommunityProvider>`). */
37
41
  successPath?: string;
38
- /** Where the "View memberships" link points when the artist sells no tiers. Default `/i/membership`. */
42
+ /** Where the "View memberships" link points when there are no tiers. Default: the community's About page. */
39
43
  membershipsPath?: string;
40
44
  /**
41
- * The fan's account page, where their applications are listed. The link
42
- * after applying points here. Default `/i/account`.
45
+ * Where the link after applying points: the community home, which shows the
46
+ * pending application. Default: the surrounding community's home.
43
47
  */
44
- accountPath?: string;
48
+ homePath?: string;
45
49
  /** Format a numeric amount for display. Defaults to the runtime currency formatter. */
46
50
  formatAmount?: (amount: number) => string;
47
51
  /**
@@ -83,9 +87,9 @@ export interface MembershipCheckoutProps {
83
87
  export function MembershipCheckout({
84
88
  initialTierId,
85
89
  onComplete,
86
- successPath = "/i/account?tab=membership&confirmSubscription=true",
87
- membershipsPath = "/i/membership",
88
- accountPath = "/i/account",
90
+ successPath,
91
+ membershipsPath: membershipsPathProp,
92
+ homePath: homePathProp,
89
93
  formatAmount,
90
94
  inviteToken,
91
95
  className,
@@ -140,12 +144,28 @@ export function MembershipCheckout({
140
144
  const taxCaption = priceTaxCaption({ pricesIncludeTax: usePricesIncludeTax() });
141
145
  const renderPayment = usePaymentRenderer();
142
146
 
147
+ const communityContext = useOptionalCommunityContext();
148
+ const slug = communityContext?.slug;
149
+ // Inside a community, the fan's membership IN IT (not `user.membership`, the
150
+ // newest one on the profile) decides "current plan" and join vs change.
151
+ const viewer = useCommunityViewerState(communityContext?.communityId ?? slug, {
152
+ enabled: !!(communityContext?.communityId ?? slug),
153
+ });
154
+ const current =
155
+ communityContext && viewer.data
156
+ ? viewer.data.membership
157
+ ? { tierId: viewer.data.membership.tierId, status: viewer.data.membership.status }
158
+ : null
159
+ : undefined;
160
+ const membershipsPath = membershipsPathProp ?? (slug ? communityAboutPath(slug) : communitiesListPath());
161
+ const homePath = homePathProp ?? (slug ? communityHomePath(slug) : communitiesListPath());
143
162
  const checkout = useMembershipCheckout({
144
163
  initialTierId,
145
164
  returnPath: successPath,
146
165
  freeReturnPath: successPath,
147
166
  onComplete,
148
167
  inviteToken,
168
+ current,
149
169
  });
150
170
  const inviteInfo = useMembershipInviteInfo(checkout.inviteToken);
151
171
 
@@ -269,16 +289,16 @@ export function MembershipCheckout({
269
289
  : t(APPLICATION_OUTCOME_BODY[checkout.applicationOutcome], { tier_name: tier.name })}
270
290
  </p>
271
291
  </div>
272
- {/* A lodged or approved application is followed on the account page.
273
- One that closed leaves the fan choosing again, so that goes back
274
- to the tiers. */}
292
+ {/* A lodged or approved application is followed inside the community,
293
+ which shows it pending. One that closed leaves the fan choosing
294
+ again, so that goes back to the tiers. */}
275
295
  {checkout.applicationOutcome === "received" || checkout.applicationOutcome === "approved" ? (
276
296
  <a
277
- href={accountPath}
278
- data-testid="membership-application-account-link"
297
+ href={homePath}
298
+ data-testid="membership-application-home-link"
279
299
  style={{ ...ghostButton, display: "block", textAlign: "center", textDecoration: "none", boxSizing: "border-box" }}
280
300
  >
281
- {t("forge.membership_checkout.go_to_account")}
301
+ {t("forge.membership_checkout.go_to_community")}
282
302
  </a>
283
303
  ) : (
284
304
  <a
@@ -445,7 +465,7 @@ export function MembershipCheckout({
445
465
  returnUrl:
446
466
  checkout.setupPurpose === "application" && checkout.applicationReturnUrl
447
467
  ? checkout.applicationReturnUrl
448
- : `${typeof window !== "undefined" ? window.location.origin : ""}${successPath}`,
468
+ : `${typeof window !== "undefined" ? window.location.origin : ""}${checkout.returnPath}`,
449
469
  amount: checkout.amount,
450
470
  currency: settlementCurrency,
451
471
  mode: "manual",
@@ -4,6 +4,8 @@ import { useForgeT } from "../../i18n";
4
4
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
5
  import { useAmountFormatter } from "../format/useFormatCurrency";
6
6
  import { membershipPriceLabel } from "../format/membershipPriceLabel";
7
+ import { usePublicCommunities } from "../../data/queries/useCommunities";
8
+ import { communityAboutPath } from "../headless/communities/paths";
7
9
 
8
10
  export interface MembershipTierCalloutProps {
9
11
  /** The tier the visitor is on their way to join. Nothing is drawn without it. */
@@ -30,6 +32,9 @@ export interface MembershipTierCalloutProps {
30
32
  * site remembers too. The client app has always shown this; code websites did
31
33
  * not, so this is the same reassurance in a Forge component.
32
34
  *
35
+ * A tier belongs to exactly one community, so the callout names it and links
36
+ * to its About page with the tier preselected, where the join happens in place.
37
+ *
33
38
  * Renders NOTHING when there is no tier id, when the tiers have not loaded, or
34
39
  * when the id matches nothing the artist sells. A form that pushes itself down
35
40
  * the page a beat after it appeared is worse than one that never moved, and an
@@ -50,6 +55,9 @@ export function MembershipTierCallout({
50
55
  const { data: tiers } = useGetMembershipTiers(inviteToken ? { inviteToken } : {});
51
56
  const { data: invite } = useMembershipInviteInfo(inviteToken);
52
57
  const tier = membershipTierId ? tiers?.find((candidate) => candidate.id === membershipTierId) : undefined;
58
+ const tierCommunityId = (tier as { communityId?: string } | undefined)?.communityId;
59
+ const { data: communities } = usePublicCommunities({ enabled: !!tierCommunityId });
60
+ const community = tierCommunityId ? communities?.find((candidate) => candidate.id === tierCommunityId) : undefined;
53
61
 
54
62
  if (!tier) return null;
55
63
 
@@ -76,7 +84,18 @@ export function MembershipTierCallout({
76
84
  <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 8, fontFamily: tokens.headingFontFamily }}>
77
85
  {t("forge.membership_tier_callout.join_title", { tier_name: tier.name })}
78
86
  </h2>
79
- <p style={{ fontSize: 14, marginBottom: shown.length || invite ? 12 : 0 }}>{membershipPriceLabel(tier, t, fmt)}</p>
87
+ <p style={{ fontSize: 14, marginBottom: shown.length || invite || community ? 12 : 0 }}>{membershipPriceLabel(tier, t, fmt)}</p>
88
+ {community && (
89
+ <p style={{ fontSize: 14, marginBottom: shown.length || invite ? 12 : 0 }}>
90
+ <a
91
+ href={communityAboutPath(community.slug, { tier: tier.id })}
92
+ data-testid="membership-tier-callout-community"
93
+ style={{ color: tokens.primary, fontWeight: 600 }}
94
+ >
95
+ {t("forge.membership_tier_callout.view_community", { community_name: community.name })}
96
+ </a>
97
+ </p>
98
+ )}
80
99
  {invite && invite.membershipTierId === tier.id && (
81
100
  <p style={{ fontSize: 14, fontWeight: 600, marginBottom: shown.length ? 12 : 0 }}>
82
101
  {t("forge.membership_tier_callout.invite_for_email", { email: invite.email })}
@@ -14,6 +14,9 @@ import { usePricesIncludeTax } from "../../data/queries/useWebsite";
14
14
  import { useForgeT } from "../../i18n";
15
15
  import { PublicAuthContext } from "../../contexts/PublicAuthContext";
16
16
  import { membershipPriceLabel } from "../format/membershipPriceLabel";
17
+ import { CommunityProvider, useOptionalCommunityContext } from "../../contexts/CommunityContext";
18
+ import { usePublicCommunities } from "../../data/queries/useCommunities";
19
+ import { communityAboutPath, communitiesListPath } from "../headless/communities/paths";
17
20
  import { Button } from "./Button";
18
21
  import { Loading } from "./Loading";
19
22
 
@@ -21,8 +24,16 @@ export interface MembershipTiersProps {
21
24
  /** Format a numeric amount for display. Defaults to `$X.XX`. */
22
25
  formatAmount?: (amount: number) => string;
23
26
  /**
24
- * Where "Select" sends the buyer. The chosen tier id is appended as the
25
- * `membershipTierId` query param. Default `/i/membership/checkout`.
27
+ * The community whose tiers to show (slug or id). Omitted, the surrounding
28
+ * `<CommunityProvider>` is used; outside one, every tier on the profile is
29
+ * listed and each links to its own community.
30
+ */
31
+ community?: string;
32
+ /**
33
+ * Override where "Select" sends the buyer; the tier id is appended as the
34
+ * `membershipTierId` query param. Default: the tier's community About page
35
+ * with the tier preselected (`/i/communities/<slug>?tier=<id>`), which opens
36
+ * the join sheet in place.
26
37
  */
27
38
  checkoutPath?: string;
28
39
  /**
@@ -39,7 +50,11 @@ export interface MembershipTiersProps {
39
50
  }
40
51
 
41
52
  /**
42
- * Drop-in membership page. Renders every membership tier as a centered,
53
+ * Drop-in tier cards. `community` scopes them to one community:
54
+ *
55
+ * <MembershipTiers community="fan-club" />
56
+ *
57
+ * Renders every membership tier as a centered,
43
58
  * responsive card row (a single tier sits at a natural card width rather than
44
59
  * stretching full-screen). "Select" routes to `checkoutPath` with the tier id,
45
60
  * or calls `onSelect` when provided.
@@ -53,14 +68,25 @@ export interface MembershipTiersProps {
53
68
  * "{days}-day free trial" badge, and an open tier's button reads "Start free
54
69
  * trial". An application tier keeps "Apply to join" (approval starts the trial).
55
70
  */
56
- export function MembershipTiers({
71
+ export function MembershipTiers({ community, ...props }: MembershipTiersProps) {
72
+ if (community) {
73
+ return (
74
+ <CommunityProvider community={community}>
75
+ <MembershipTierCards {...props} />
76
+ </CommunityProvider>
77
+ );
78
+ }
79
+ return <MembershipTierCards {...props} />;
80
+ }
81
+
82
+ function MembershipTierCards({
57
83
  formatAmount,
58
- checkoutPath = "/i/membership/checkout",
84
+ checkoutPath,
59
85
  onSelect,
60
86
  heading,
61
87
  className,
62
88
  style,
63
- }: MembershipTiersProps) {
89
+ }: Omit<MembershipTiersProps, "community">) {
64
90
  const tokens = useThemeTokens();
65
91
  const t = useForgeT();
66
92
  const fmt = useAmountFormatter(formatAmount);
@@ -68,6 +94,9 @@ export function MembershipTiers({
68
94
  const pricesIncludeTax = usePricesIncludeTax();
69
95
  const taxCaption = priceTaxCaption({ pricesIncludeTax });
70
96
  const { data: tiers, isLoading } = useGetMembershipTiers();
97
+ const context = useOptionalCommunityContext();
98
+ // Only needed to link a tier to its community when there is no provider.
99
+ const { data: communities } = usePublicCommunities({ enabled: !context && !checkoutPath && !onSelect });
71
100
  const withdraw = useWithdrawMembershipApplication();
72
101
  const [withdrawError, setWithdrawError] = useState<string | null>(null);
73
102
  // `null` still means "no heading"; anything else falls back to the default.
@@ -82,15 +111,24 @@ export function MembershipTiers({
82
111
  // the same sentence this page does.
83
112
  const priceLabel = (tier: MembershipTier) => membershipPriceLabel(tier, t, fmt);
84
113
 
85
- const select = (tier: MembershipTier) => {
86
- if (onSelect) return onSelect(tier);
87
- if (typeof window !== "undefined") {
114
+ /** A tier belongs to exactly one community; its About page is where it is joined. */
115
+ const tierHref = (tier: MembershipTier): string => {
116
+ const invite = readMembershipInviteFromUrl();
117
+ if (checkoutPath) {
88
118
  const sep = checkoutPath.includes("?") ? "&" : "?";
89
- const invite = readMembershipInviteFromUrl();
90
- window.location.href = `${checkoutPath}${sep}membershipTierId=${encodeURIComponent(tier.id)}${
119
+ return `${checkoutPath}${sep}membershipTierId=${encodeURIComponent(tier.id)}${
91
120
  invite ? `&invite=${encodeURIComponent(invite)}` : ""
92
121
  }`;
93
122
  }
123
+ const tierCommunityId = (tier as MembershipTier & { communityId?: string }).communityId;
124
+ const slug =
125
+ context?.slug ?? communities?.find((candidate) => candidate.id === tierCommunityId)?.slug ?? undefined;
126
+ return slug ? communityAboutPath(slug, { tier: tier.id, ...(invite ? { invite } : {}) }) : communitiesListPath();
127
+ };
128
+
129
+ const select = (tier: MembershipTier) => {
130
+ if (onSelect) return onSelect(tier);
131
+ if (typeof window !== "undefined") window.location.href = tierHref(tier);
94
132
  };
95
133
 
96
134
  return (
@@ -8,7 +8,7 @@ export interface PaywallProps {
8
8
  onAction?: () => void;
9
9
  }
10
10
 
11
- /** A themed "members only" card — pair with `<MembershipGate fallback={...}>`. */
11
+ /** A themed "members only" card. Pair with `<CommunityGate fallback={...}>`. */
12
12
  export function Paywall({
13
13
  title = "Members only",
14
14
  message = "This content is available to members. Join to unlock it.",
@@ -106,7 +106,7 @@ function render(rows: MyBooking[]): string {
106
106
  <ForgeThemeProvider
107
107
  theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
108
108
  >
109
- <AccountDashboard tab="bookings" onNavigateMembership={() => {}} />
109
+ <AccountDashboard tab="bookings" />
110
110
  </ForgeThemeProvider>
111
111
  </PublicAuthContext.Provider>
112
112
  </ForgeClientProvider>
@@ -0,0 +1,151 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, afterEach, vi } from "vitest";
3
+ import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
4
+ import axios, { type AxiosAdapter, type InternalAxiosRequestConfig } from "axios";
5
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
+ import { ForgeClientProvider } from "../../../provider/ForgeProvider";
7
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
8
+ import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
9
+ import { AccountDashboard } from "../AccountDashboard";
10
+
11
+ /**
12
+ * The account page's Memberships tab. A fan can belong to several communities
13
+ * of one creator, so the tab lists ONE CARD PER COMMUNITY from
14
+ * `/public/memberships/mine`, never the account's single legacy membership.
15
+ * Managing a membership happens inside its community, so each card opens it.
16
+ */
17
+
18
+ const DAY = 24 * 60 * 60 * 1000;
19
+ const inDays = (n: number) => new Date(Date.now() + n * DAY).toISOString();
20
+ const fmt = (iso: string) => new Date(iso).toLocaleDateString("en", { year: "numeric", month: "short", day: "numeric" });
21
+
22
+ const fan = {
23
+ id: "account-1",
24
+ email: "fan@example.com",
25
+ firstName: "Fan",
26
+ lastName: "Person",
27
+ kind: "fan",
28
+ status: "active",
29
+ createdAt: "2026-01-01T00:00:00.000Z",
30
+ updatedAt: "2026-01-01T00:00:00.000Z",
31
+ // The legacy single membership must NOT be what the tab shows.
32
+ membership: { id: "legacy", membershipTierId: "t-legacy", status: "active", membershipTier: { id: "t-legacy", name: "LEGACY" } },
33
+ };
34
+
35
+ let realAdapter: AxiosAdapter | undefined;
36
+ let requested: string[] = [];
37
+
38
+ function mount(mine: unknown[], applications: unknown[] = [], communities: unknown[] = []) {
39
+ requested = [];
40
+ realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
41
+ axios.defaults.adapter = (async (config: InternalAxiosRequestConfig) => {
42
+ const url = config.url ?? "";
43
+ requested.push(url);
44
+ // `/public/memberships/mine` answers the server's real envelope,
45
+ // `[{ community, membership }]`, built from the flat rows the tests list.
46
+ const data = url.includes("/public/memberships/mine")
47
+ ? (mine as Array<Record<string, unknown>>).map(({ community, subscriptionAmount, subscriptionCurrency, ...membership }) => ({
48
+ community,
49
+ membership: { ...membership, amount: subscriptionAmount ?? null, currency: subscriptionCurrency ?? null },
50
+ }))
51
+ : url.includes("/public/membership-applications/mine")
52
+ ? applications
53
+ : url === "/public/communities"
54
+ ? communities
55
+ : [];
56
+ return { data, status: 200, statusText: "OK", headers: {}, config } as never;
57
+ }) as AxiosAdapter;
58
+ const navigate = vi.fn();
59
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
60
+ render(
61
+ <QueryClientProvider client={queryClient}>
62
+ <ForgeClientProvider apiUrl="http://api.test" profileId="profile-1">
63
+ <PublicAuthContext.Provider value={{ user: fan, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}>
64
+ <ForgeThemeProvider theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}>
65
+ <AccountDashboard tab="membership" navigate={navigate} />
66
+ </ForgeThemeProvider>
67
+ </PublicAuthContext.Provider>
68
+ </ForgeClientProvider>
69
+ </QueryClientProvider>,
70
+ );
71
+ return { navigate };
72
+ }
73
+
74
+ afterEach(() => {
75
+ cleanup();
76
+ if (realAdapter) axios.defaults.adapter = realAdapter;
77
+ });
78
+
79
+ const row = (overrides: Record<string, unknown>) => ({
80
+ id: "m-1",
81
+ tierId: "tier-1",
82
+ tierName: "Inner Circle",
83
+ status: "active",
84
+ cancelAtPeriodEnd: false,
85
+ currentPeriodEnd: inDays(20),
86
+ subscriptionAmount: 10,
87
+ subscriptionCurrency: "USD",
88
+ billingCycle: "month",
89
+ community: { id: "c-1", name: "Fan Club", slug: "fan-club" },
90
+ ...overrides,
91
+ });
92
+
93
+ describe("AccountDashboard: My memberships", () => {
94
+ it("lists one card per community, from the memberships endpoint, not the legacy single membership", async () => {
95
+ mount([row({}), row({ id: "m-2", tierName: "Producers", community: { id: "c-2", name: "Producers Circle", slug: "producers" } })]);
96
+
97
+ const cards = await screen.findAllByTestId("my-membership");
98
+ expect(cards).toHaveLength(2);
99
+ expect(cards[0].textContent).toContain("Fan Club");
100
+ expect(cards[0].textContent).toContain("Inner Circle");
101
+ expect(cards[1].textContent).toContain("Producers Circle");
102
+ expect(document.body.textContent).not.toContain("LEGACY");
103
+ expect(requested).toContain("/public/memberships/mine");
104
+ });
105
+
106
+ it("opens the membership's community home, never the account page", async () => {
107
+ const { navigate } = mount([row({})]);
108
+ const card = await screen.findByTestId("my-membership");
109
+ fireEvent.click(within(card).getByRole("button", { name: "Open community" }));
110
+ expect(navigate).toHaveBeenCalledWith("/i/communities/fan-club/home");
111
+ });
112
+
113
+ it("says Renews on for a renewing membership and Ends on for a cancelled one", async () => {
114
+ const end = inDays(20);
115
+ mount([row({ currentPeriodEnd: end }), row({ id: "m-2", cancelAtPeriodEnd: true, currentPeriodEnd: end, community: { id: "c-2", name: "B", slug: "b" } })]);
116
+ const [renewing, ending] = await screen.findAllByTestId("my-membership");
117
+ expect(renewing.textContent).toContain(`Renews on ${fmt(end)}`);
118
+ expect(ending.textContent).toContain(`Ends on ${fmt(end)}`);
119
+ });
120
+
121
+ it("shows the trial end instead of the renewal during a free trial", async () => {
122
+ const ends = inDays(10);
123
+ mount([row({ trialEndsAt: ends, currentPeriodEnd: ends })]);
124
+ expect((await screen.findByTestId("my-membership-trial")).textContent).toContain(`Free trial until ${fmt(ends)}`);
125
+ expect(document.body.textContent).not.toContain("Renews on");
126
+ });
127
+
128
+ it("says so when the fan belongs to no community", async () => {
129
+ mount([]);
130
+ expect((await screen.findByTestId("my-memberships-empty")).textContent).toContain("not a member of any community");
131
+ });
132
+
133
+ it("finishes a payment_failed application on its community's About page, on that tier", async () => {
134
+ mount(
135
+ [],
136
+ [
137
+ {
138
+ id: "app-1",
139
+ membershipTierId: "tier-9",
140
+ membershipTierName: "Gold",
141
+ communityId: "c-9",
142
+ status: "payment_failed",
143
+ createdAt: "2026-09-01T00:00:00.000Z",
144
+ },
145
+ ],
146
+ [{ id: "c-9", name: "Gold Club", slug: "gold-club", memberCount: 1, tags: [] }],
147
+ );
148
+ const link = (await screen.findByRole("link", { name: /Complete/i })) as HTMLAnchorElement;
149
+ await vi.waitFor(() => expect(link.getAttribute("href")).toBe("/i/communities/gold-club?tier=tier-9"));
150
+ });
151
+ });
@@ -106,7 +106,7 @@ function render(opts: { rentals: MyFilmRental[] | null; tab?: AccountTabKey }):
106
106
  <ForgeThemeProvider
107
107
  theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
108
108
  >
109
- <AccountDashboard tab={opts.tab ?? "rentals"} onNavigateMembership={() => {}} />
109
+ <AccountDashboard tab={opts.tab ?? "rentals"} />
110
110
  </ForgeThemeProvider>
111
111
  </PublicAuthContext.Provider>
112
112
  </ForgeClientProvider>
@@ -129,7 +129,13 @@ describe("CourseCallsSection", () => {
129
129
  id: "call-2",
130
130
  title: "Week 0 orientation",
131
131
  isOpen: false,
132
- recording: { id: "rec-1", status: "ready", durationSeconds: 3600, watchUrl: "https://cdn.test/rec.mp4" },
132
+ recording: {
133
+ id: "rec-1",
134
+ status: "ready",
135
+ durationSeconds: 3600,
136
+ watchUrl: "https://cdn.test/rec.mp4",
137
+ transcript: null,
138
+ },
133
139
  }),
134
140
  );
135
141
  const { getAllByText, container } = mount();
@@ -8,6 +8,7 @@ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
8
8
  import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
9
9
  import { Currency, type MembershipTier, type PublicAuthUser } from "../../../types/models";
10
10
  import { MembershipCheckout } from "../MembershipCheckout";
11
+ import { InCommunity } from "../../../_tests/testCommunity";
11
12
 
12
13
  /**
13
14
  * Membership signup, end to end, over the REAL query/mutation layer.
@@ -66,6 +67,14 @@ const currencies = {
66
67
  exchangeRates: { [Currency.USD]: { [Currency.NGN]: 1500 } },
67
68
  };
68
69
 
70
+ /**
71
+ * The viewer's membership IN THIS COMMUNITY, as `GET /public/communities/:ref/me`
72
+ * reports it. Inside a community the checkout reads the current tier from
73
+ * here, never from `user.membership` (the newest membership on the whole
74
+ * profile, which can belong to another community).
75
+ */
76
+ let viewerMembership: { tierId: string; status: string } | null = null;
77
+
69
78
  function installAdapter(tiers: MembershipTier[]) {
70
79
  sent = [];
71
80
  const adapter: AxiosAdapter = async (config: InternalAxiosRequestConfig) => {
@@ -76,6 +85,15 @@ function installAdapter(tiers: MembershipTier[]) {
76
85
  const reply = (data: unknown) =>
77
86
  Promise.resolve({ data, status: 200, statusText: "OK", headers: {}, config } as never);
78
87
 
88
+ if (/\/public\/communities\/[^/]+\/me$/.test(url)) {
89
+ return reply({
90
+ state: viewerMembership ? "member" : "none",
91
+ membership: viewerMembership,
92
+ application: null,
93
+ upgradeTiers: [],
94
+ canInteract: true,
95
+ });
96
+ }
79
97
  if (url.includes("/public/membership-tiers")) return reply(tiers);
80
98
  if (url.includes("/public/websites/site-config")) return reply({ currency: "USD" });
81
99
  if (url.includes("/public/currencies")) return reply(currencies);
@@ -107,7 +125,13 @@ function installAdapter(tiers: MembershipTier[]) {
107
125
  axios.defaults.adapter = adapter;
108
126
  }
109
127
 
110
- function mount(opts: { tiers: MembershipTier[]; user?: PublicAuthUser; initialTierId?: string }) {
128
+ function mount(opts: {
129
+ tiers: MembershipTier[];
130
+ user?: PublicAuthUser;
131
+ initialTierId?: string;
132
+ viewerMembership?: { tierId: string; status: string } | null;
133
+ }) {
134
+ viewerMembership = opts.viewerMembership ?? null;
111
135
  installAdapter(opts.tiers);
112
136
  // `"initialTierId" in opts` rather than `??`, so a test can ask for NO
113
137
  // preselected tier (the grid) without the default quietly putting one back.
@@ -130,7 +154,9 @@ function mount(opts: { tiers: MembershipTier[]; user?: PublicAuthUser; initialTi
130
154
  <ForgeThemeProvider
131
155
  theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
132
156
  >
133
- <MembershipCheckout initialTierId={initialTierId} />
157
+ <InCommunity>
158
+ <MembershipCheckout initialTierId={initialTierId} />
159
+ </InCommunity>
134
160
  </ForgeThemeProvider>
135
161
  </PublicAuthContext.Provider>
136
162
  </ForgeClientProvider>
@@ -344,10 +370,9 @@ describe("billing cycles a tier actually sells", () => {
344
370
 
345
371
  describe("the tier grid, when no tier was chosen up front", () => {
346
372
  it("does not offer the tier the member is already on", async () => {
347
- const onTier = { ...member, membership: { membershipTierId: "tier-1" } } as unknown as PublicAuthUser;
348
373
  mount({
349
374
  tiers: [tier({ id: "tier-1", name: "Inner Circle", priceMonthly: 10 }), tier({ id: "tier-2", name: "Backstage", priceMonthly: 20 })],
350
- user: onTier,
375
+ viewerMembership: { tierId: "tier-1", status: "active" },
351
376
  initialTierId: undefined,
352
377
  });
353
378
 
@@ -371,13 +396,31 @@ describe("the tier grid, when no tier was chosen up front", () => {
371
396
  });
372
397
 
373
398
  it("calls a change a change for a member with a live subscription", async () => {
374
- const paying = {
399
+ mount({
400
+ tiers: [tier({ id: "tier-2", name: "Backstage", priceMonthly: 20 })],
401
+ viewerMembership: { tierId: "tier-1", status: "active" },
402
+ initialTierId: "tier-2",
403
+ });
404
+
405
+ expect(await screen.findByRole("button", { name: "Confirm change" })).toBeTruthy();
406
+ });
407
+
408
+ it("REGRESSION: a membership in ANOTHER community does not turn a join into a change", async () => {
409
+ // `user.membership` is the newest membership on the profile. Here it lives
410
+ // in a different community, and this community's `/me` says: not a member.
411
+ const inOtherCommunity = {
375
412
  ...member,
376
- membership: { membershipTierId: "tier-1", status: "active", paymentProviderSubscriptionId: "sub_123" },
413
+ membership: { membershipTierId: "tier-elsewhere", status: "active", paymentProviderSubscriptionId: "sub_other" },
377
414
  } as unknown as PublicAuthUser;
378
- mount({ tiers: [tier({ id: "tier-2", name: "Backstage", priceMonthly: 20 })], user: paying, initialTierId: "tier-2" });
415
+ mount({
416
+ tiers: [tier({ id: "tier-2", name: "Backstage", priceMonthly: 20 })],
417
+ user: inOtherCommunity,
418
+ viewerMembership: null,
419
+ initialTierId: "tier-2",
420
+ });
379
421
 
380
- expect(await screen.findByRole("button", { name: "Confirm change" })).toBeTruthy();
422
+ await waitFor(() => expect(sent.some((r) => /\/me$/.test(r.url))).toBe(true));
423
+ expect(screen.queryByRole("button", { name: "Confirm change" })).toBeNull();
381
424
  });
382
425
  });
383
426
 
@@ -452,7 +495,7 @@ describe("tier questionnaires", () => {
452
495
  ]);
453
496
  });
454
497
 
455
- it("after applying, links to the account page rather than the membership list", async () => {
498
+ it("after applying, links inside the community (where it shows pending), never to the account page", async () => {
456
499
  mount({ tiers: [tier({ priceMonthly: 0, joinMode: "application", questionnaire: questions })] });
457
500
 
458
501
  fireEvent.change(await screen.findByLabelText(/Why do you want to join\?/), { target: { value: "Big fan" } });
@@ -463,8 +506,8 @@ describe("tier questionnaires", () => {
463
506
  expect(applicationPosts()[0].body).not.toHaveProperty("message");
464
507
  expect(applicationPosts()[0].body?.questionnaire).toHaveLength(3);
465
508
  expect(await screen.findByText("Application received")).toBeTruthy();
466
- const link = screen.getByRole("link", { name: "Go to your account" }) as HTMLAnchorElement;
467
- expect(link.getAttribute("href")).toBe("/i/account");
509
+ const link = screen.getByRole("link", { name: "Go to the community" }) as HTMLAnchorElement;
510
+ expect(link.getAttribute("href")).toBe("/i/communities/fan-club/home");
468
511
  expect(screen.queryByRole("link", { name: "View memberships" })).toBeNull();
469
512
  });
470
513
  });
@@ -11,7 +11,7 @@ import type { MembershipTier } from "../../../types/models";
11
11
  /**
12
12
  * What a fan sees after pressing Join while signed out.
13
13
  *
14
- * The auth guard sends them to `/i/login?redirect=/i/membership/checkout?membershipTierId=…`,
14
+ * The auth guard sends them to `/i/login?redirect=/i/communities/<slug>?tier=…`,
15
15
  * which up to now was a bare sign-in card: no mention of the tier they had just
16
16
  * chosen, no sign that the wall was part of joining at all. The client app has
17
17
  * always shown the tier on this screen, so this is code websites catching up.
@@ -121,7 +121,7 @@ describe("MembershipTiers: the card per join mode and application state", () =>
121
121
 
122
122
  describe("the invite token reaches the tier list", () => {
123
123
  it("forwards `?invite=` as inviteToken", async () => {
124
- window.history.replaceState(null, "", "/i/membership?invite=tok-123");
124
+ window.history.replaceState(null, "", "/i/communities/fan-club?invite=tok-123");
125
125
  mount([tier("inv", { joinMode: "invite" })]);
126
126
  await cardOf("Tier inv");
127
127