@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.
- package/package.json +1 -1
- package/src/_tests/testCommunity.tsx +28 -0
- package/src/contexts/CommunityContext.tsx +192 -0
- package/src/data/queries/useChat.ts +42 -39
- package/src/data/queries/useCommunities.ts +343 -0
- package/src/data/queries/useCommunity.ts +93 -89
- package/src/data/queries/useMembership.ts +32 -7
- package/src/data/queries/useMembershipCalls.ts +116 -7
- package/src/data/queries/usePostCollections.ts +28 -16
- package/src/data/queries/usePosts.ts +16 -14
- package/src/data/queries/useSubscriptions.ts +22 -8
- package/src/i18n/de.json +145 -1
- package/src/i18n/en.json +145 -1
- package/src/index.ts +19 -7
- package/src/runtime/pages.spec.ts +31 -0
- package/src/runtime/pages.ts +21 -22
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
- package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
- package/src/ui/format/membershipGate.ts +19 -15
- package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
- package/src/ui/headless/_tests/communityHarness.tsx +205 -0
- package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
- package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +382 -0
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +151 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +286 -0
- package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
- package/src/ui/headless/auth/useSignupForm.ts +10 -2
- package/src/ui/headless/communities/CommunityGate.tsx +67 -0
- package/src/ui/headless/communities/index.ts +55 -0
- package/src/ui/headless/communities/paths.ts +36 -0
- package/src/ui/headless/communities/useCommunity.ts +140 -0
- package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
- package/src/ui/headless/communities/useCommunityHome.ts +88 -0
- package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +444 -0
- package/src/ui/headless/communities/useCommunityMembership.ts +136 -0
- package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
- package/src/ui/headless/communities/useMyMemberships.ts +59 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
- package/src/ui/headless/index.ts +8 -4
- package/src/ui/headless/memberHome/index.ts +1 -1
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +44 -42
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
- package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
- package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
- package/src/ui/headless/posts/upsell.ts +54 -8
- package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
- package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
- package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
- package/src/ui/headless/posts/usePostItem.ts +6 -5
- package/src/ui/index.ts +17 -12
- package/src/ui/styled/AccountDashboard.tsx +48 -256
- package/src/ui/styled/MembershipCheckout.tsx +37 -17
- package/src/ui/styled/MembershipTierCallout.tsx +20 -1
- package/src/ui/styled/MembershipTiers.tsx +49 -11
- package/src/ui/styled/Paywall.tsx +1 -1
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +1 -1
- package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
- package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
- package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
- package/src/ui/styled/communities/CommunityAbout.tsx +140 -0
- package/src/ui/styled/communities/CommunityHome.tsx +175 -0
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +444 -0
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +133 -0
- package/src/ui/styled/communities/CommunityPending.tsx +49 -0
- package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +89 -0
- package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
- package/src/ui/styled/communities/index.ts +10 -0
- package/src/ui/styled/communities/shared.tsx +149 -0
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
- package/src/ui/styled/forge-utilities.css +19 -0
- package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +142 -10
- package/src/ui/styled/members/MemberHomeLayout.tsx +35 -17
- package/src/ui/styled/members/MemberPostCard.tsx +9 -5
- package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
- package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
- package/src/ui/styled/members/callTranscriptText.ts +67 -0
- package/src/utils/_tests/safeRedirect.spec.ts +2 -2
- package/src/ui/headless/membership/MembershipGate.tsx +0 -44
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
- 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
|
|
29
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
42
|
-
*
|
|
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
|
-
|
|
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
|
|
87
|
-
membershipsPath
|
|
88
|
-
|
|
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
|
|
273
|
-
One that closed leaves the fan choosing
|
|
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={
|
|
278
|
-
data-testid="membership-application-
|
|
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.
|
|
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 : ""}${
|
|
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
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
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"
|
|
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"}
|
|
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: {
|
|
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: {
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
413
|
+
membership: { membershipTierId: "tier-elsewhere", status: "active", paymentProviderSubscriptionId: "sub_other" },
|
|
377
414
|
} as unknown as PublicAuthUser;
|
|
378
|
-
mount({
|
|
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
|
-
|
|
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
|
|
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
|
|
467
|
-
expect(link.getAttribute("href")).toBe("/i/
|
|
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/
|
|
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/
|
|
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
|
|