@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.
- 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
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
80
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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}${
|
|
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 ??
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
53
|
+
// Only pulled once something is actually locked: an ungated storefront must
|
|
48
54
|
// not acquire a request it never needed.
|
|
49
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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 = "/
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
74
|
+
/** First media item (null when locked: the server ships none). */
|
|
74
75
|
media,
|
|
75
76
|
audioTrack,
|
|
76
77
|
audioQueue,
|