@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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useContext, useMemo } from "react";
|
|
2
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
3
|
+
import { useOptionalCommunityContext, MissingCommunityProviderError } from "../../../contexts/CommunityContext";
|
|
4
|
+
import {
|
|
5
|
+
isNotFound,
|
|
6
|
+
usePublicCommunities,
|
|
7
|
+
usePublicCommunity,
|
|
8
|
+
useCommunityViewerState,
|
|
9
|
+
type CommunityTokens,
|
|
10
|
+
type CommunityViewerState,
|
|
11
|
+
type PublicCommunity,
|
|
12
|
+
type PublicTier,
|
|
13
|
+
} from "../../../data/queries/useCommunities";
|
|
14
|
+
import { cycleFloor, cycleIsFree, defaultCycle, offeredCycles, type BillingCycle } from "../../format/membershipPwyw";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every community on the profile, for a list page.
|
|
18
|
+
*
|
|
19
|
+
* `only` is the one community when there is exactly one, so a list page can go
|
|
20
|
+
* straight to it rather than asking the fan to choose between one option.
|
|
21
|
+
*/
|
|
22
|
+
export function useCommunities() {
|
|
23
|
+
const query = usePublicCommunities();
|
|
24
|
+
const communities = query.data ?? [];
|
|
25
|
+
return {
|
|
26
|
+
communities,
|
|
27
|
+
only: communities.length === 1 ? communities[0]! : null,
|
|
28
|
+
isLoading: query.isLoading,
|
|
29
|
+
isError: query.isError,
|
|
30
|
+
refetch: query.refetch,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What the About page's single Join button offers, decided from the creator's tiers. */
|
|
35
|
+
export type CommunityJoinOffer =
|
|
36
|
+
| { kind: "free"; tier: PublicTier }
|
|
37
|
+
| { kind: "paid"; tier: PublicTier; amount: number; cycle: BillingCycle }
|
|
38
|
+
| { kind: "apply"; tier: PublicTier }
|
|
39
|
+
| null;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The Join button reflects the creator's tiers (decision 8): "Join free" when a
|
|
43
|
+
* free tier exists, otherwise "Join" with the lowest price, and "Apply" when
|
|
44
|
+
* every tier is apply-to-join. Pure, so a custom About page words it the same.
|
|
45
|
+
*/
|
|
46
|
+
export function communityJoinOffer(tiers: PublicTier[]): CommunityJoinOffer {
|
|
47
|
+
if (!tiers.length) return null;
|
|
48
|
+
const joinable = tiers.filter((tier) => tier.joinMode !== "application");
|
|
49
|
+
const free = joinable.find((tier) => cycleIsFree(tier, defaultCycle(tier)));
|
|
50
|
+
if (free) return { kind: "free", tier: free };
|
|
51
|
+
let cheapest: { tier: PublicTier; amount: number; cycle: BillingCycle } | null = null;
|
|
52
|
+
for (const tier of joinable) {
|
|
53
|
+
const cycles = offeredCycles(tier);
|
|
54
|
+
// A pay-what-you-want tier with no floor sells no cycle at a price, and is
|
|
55
|
+
// still a paid join from nothing.
|
|
56
|
+
const candidates = (["month", "year"] as const).filter((cycle) => cycles[cycle]);
|
|
57
|
+
for (const cycle of candidates.length ? candidates : (["month"] as const)) {
|
|
58
|
+
const amount = cycleFloor(tier, cycle);
|
|
59
|
+
// Compare on a monthly footing so a yearly price is not mistaken for the dearer one.
|
|
60
|
+
const monthly = cycle === "year" ? amount / 12 : amount;
|
|
61
|
+
const best = cheapest ? (cheapest.cycle === "year" ? cheapest.amount / 12 : cheapest.amount) : Infinity;
|
|
62
|
+
if (monthly < best) cheapest = { tier, amount, cycle };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (cheapest) return { kind: "paid", ...cheapest };
|
|
66
|
+
return { kind: "apply", tier: tiers[0]! };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The viewer states that hold access to member content. `past_due` keeps it through the grace window. */
|
|
70
|
+
export const COMMUNITY_ACCESS_STATES: ReadonlyArray<CommunityViewerState["state"]> = ["member", "past_due"];
|
|
71
|
+
|
|
72
|
+
/** Does this viewer state grant access to the community's member content. */
|
|
73
|
+
export function viewerHasAccess(viewer: Pick<CommunityViewerState, "state"> | null | undefined): boolean {
|
|
74
|
+
return !!viewer && COMMUNITY_ACCESS_STATES.includes(viewer.state);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const NO_TIERS: PublicTier[] = [];
|
|
78
|
+
|
|
79
|
+
export interface UseCommunityOptions extends CommunityTokens {
|
|
80
|
+
enabled?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One community: what it is, the tiers this viewer may join, and where the
|
|
85
|
+
* viewer stands IN IT (`none`, `pending`, `member`, `past_due`, `cancelled`, or
|
|
86
|
+
* `anonymous`), plus the upgrade and pending details a custom page needs.
|
|
87
|
+
*
|
|
88
|
+
* `slug` is a slug or an id. Omitted, the surrounding `<CommunityProvider>` is
|
|
89
|
+
* read, and outside one this throws rather than guess a community.
|
|
90
|
+
*
|
|
91
|
+
* The viewer comes from `/me` once signed in, so it follows a sign-in that
|
|
92
|
+
* happened on this page (the join sheet) without a reload.
|
|
93
|
+
*/
|
|
94
|
+
export function useCommunity(slug?: string, opts: UseCommunityOptions = {}) {
|
|
95
|
+
const context = useOptionalCommunityContext();
|
|
96
|
+
const ref = slug ?? context?.ref;
|
|
97
|
+
if (!ref) throw new MissingCommunityProviderError("useCommunity()");
|
|
98
|
+
const tokens: CommunityTokens = { invite: opts.invite ?? null, join: opts.join ?? null };
|
|
99
|
+
const signedIn = !!useContext(PublicAuthContext)?.user;
|
|
100
|
+
|
|
101
|
+
const detail = usePublicCommunity(ref, tokens, { enabled: opts.enabled });
|
|
102
|
+
const me = useCommunityViewerState(ref, { enabled: signedIn && opts.enabled !== false });
|
|
103
|
+
|
|
104
|
+
const community: PublicCommunity | undefined = detail.data?.community ?? context?.community;
|
|
105
|
+
const tiers: PublicTier[] = detail.data?.tiers ?? NO_TIERS;
|
|
106
|
+
const viewer: CommunityViewerState | undefined = (signedIn ? me.data : undefined) ?? detail.data?.viewer;
|
|
107
|
+
|
|
108
|
+
return useMemo(() => {
|
|
109
|
+
const membership = viewer?.membership ?? null;
|
|
110
|
+
const currentTier = membership ? (tiers.find((tier) => tier.id === membership.tierId) ?? null) : null;
|
|
111
|
+
return {
|
|
112
|
+
community,
|
|
113
|
+
communityId: community?.id,
|
|
114
|
+
slug: community?.slug,
|
|
115
|
+
tiers,
|
|
116
|
+
viewer,
|
|
117
|
+
/** The invite the URL carried, as the community read resolved it. */
|
|
118
|
+
invite: detail.data?.invite ?? null,
|
|
119
|
+
isLoading: detail.isLoading || (signedIn && me.isLoading),
|
|
120
|
+
/** True while the viewer is being re-read, e.g. straight after signing in. */
|
|
121
|
+
viewerLoading: signedIn && (me.isLoading || me.isFetching),
|
|
122
|
+
isError: detail.isError,
|
|
123
|
+
notFound: isNotFound(detail.error),
|
|
124
|
+
/** An active member (including `past_due`, which keeps access through the grace window). */
|
|
125
|
+
isMember: viewerHasAccess(viewer),
|
|
126
|
+
/** Applied and waiting for the creator's decision: inside, but no member content. */
|
|
127
|
+
isPending: viewer?.state === "pending",
|
|
128
|
+
/** When the pending application was made, for a waiting screen. */
|
|
129
|
+
pendingSince: viewer?.state === "pending" ? (viewer.application?.createdAt ?? null) : null,
|
|
130
|
+
membership,
|
|
131
|
+
currentTier,
|
|
132
|
+
upgradeTiers: viewer?.upgradeTiers ?? [],
|
|
133
|
+
hasUpgrade: (viewer?.upgradeTiers?.length ?? 0) > 0,
|
|
134
|
+
canInteract: viewer?.canInteract ?? false,
|
|
135
|
+
joinOffer: communityJoinOffer(tiers),
|
|
136
|
+
refetch: () => Promise.all([detail.refetch(), signedIn ? me.refetch() : Promise.resolve()]),
|
|
137
|
+
};
|
|
138
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
139
|
+
}, [community, tiers, viewer, detail.data?.invite, detail.isLoading, detail.isError, detail.error, me.isLoading, me.isFetching, signedIn]);
|
|
140
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { MissingCommunityProviderError, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
|
|
3
|
+
import { useCommunity } from "./useCommunity";
|
|
4
|
+
import { communityHomePath } from "./paths";
|
|
5
|
+
|
|
6
|
+
/** What the About page's main button does for THIS viewer. */
|
|
7
|
+
export type CommunityAboutAction = "join" | "enter" | "pending";
|
|
8
|
+
|
|
9
|
+
export interface UseCommunityAboutOptions {
|
|
10
|
+
/** A per-email invite token (`?invite=`). Opens the join sheet straight away. */
|
|
11
|
+
invite?: string | null;
|
|
12
|
+
/** A shareable invite link token (`?join=`). Opens the join sheet straight away. */
|
|
13
|
+
join?: string | null;
|
|
14
|
+
/** A tier to preselect (`?tier=`). */
|
|
15
|
+
tierId?: string | null;
|
|
16
|
+
/** The host's router, for "go inside". Default a full-page visit. */
|
|
17
|
+
navigate?: (href: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The About page: the community, what each tier gives, and ONE main action.
|
|
22
|
+
*
|
|
23
|
+
* - `join` opens the join sheet in place (it never leaves the page).
|
|
24
|
+
* - `enter` for a member, who skips Join and goes straight inside.
|
|
25
|
+
* - `pending` for a fan whose application is being reviewed; they go inside to
|
|
26
|
+
* the pending screen.
|
|
27
|
+
*
|
|
28
|
+
* The sheet opens by itself when the URL carries an invite or a shareable
|
|
29
|
+
* link, already set to the invited tier. A signed-in member arriving on an
|
|
30
|
+
* invite link is still shown the sheet: accepting it may be a tier change.
|
|
31
|
+
*/
|
|
32
|
+
export function useCommunityAbout(opts: UseCommunityAboutOptions = {}) {
|
|
33
|
+
if (!useOptionalCommunityContext()) throw new MissingCommunityProviderError("useCommunityAbout()");
|
|
34
|
+
const community = useCommunity();
|
|
35
|
+
const hasToken = !!(opts.invite || opts.join);
|
|
36
|
+
const [sheetOpen, setSheetOpen] = useState(hasToken);
|
|
37
|
+
const [sheetTierId, setSheetTierId] = useState<string | null>(opts.tierId ?? null);
|
|
38
|
+
|
|
39
|
+
// A token that arrives after the first render (client-side navigation) still opens the sheet.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (hasToken) setSheetOpen(true);
|
|
42
|
+
}, [hasToken]);
|
|
43
|
+
|
|
44
|
+
const action: CommunityAboutAction = community.isMember ? "enter" : community.isPending ? "pending" : "join";
|
|
45
|
+
const homePath = community.slug ? communityHomePath(community.slug) : "";
|
|
46
|
+
|
|
47
|
+
const goInside = () => {
|
|
48
|
+
if (!homePath) return;
|
|
49
|
+
if (opts.navigate) opts.navigate(homePath);
|
|
50
|
+
else if (typeof window !== "undefined") window.location.assign(homePath);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
...community,
|
|
55
|
+
action,
|
|
56
|
+
homePath,
|
|
57
|
+
/** Run the main action: open the sheet, or go inside. */
|
|
58
|
+
primary: () => (action === "join" ? (setSheetTierId(opts.tierId ?? null), setSheetOpen(true)) : goInside()),
|
|
59
|
+
goInside,
|
|
60
|
+
sheetOpen,
|
|
61
|
+
/** The tier the sheet opens on: a tier card's own Join, or `?tier=`. */
|
|
62
|
+
sheetTierId,
|
|
63
|
+
openJoin: (tierId?: string | null) => {
|
|
64
|
+
setSheetTierId(tierId ?? opts.tierId ?? null);
|
|
65
|
+
setSheetOpen(true);
|
|
66
|
+
},
|
|
67
|
+
setSheetOpen,
|
|
68
|
+
invite: opts.invite ?? null,
|
|
69
|
+
join: opts.join ?? null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { MissingCommunityProviderError, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
|
|
3
|
+
import { useConfirmLatestSubscription } from "../../../data/queries/useSubscriptions";
|
|
4
|
+
import { useInvalidateCommunities } from "../../../data/queries/useCommunities";
|
|
5
|
+
import { useCommunity } from "./useCommunity";
|
|
6
|
+
import { communityAboutPath } from "./paths";
|
|
7
|
+
|
|
8
|
+
export interface UseCommunityHomeOptions {
|
|
9
|
+
/** `?welcome=1`: the fan just joined. */
|
|
10
|
+
welcome?: boolean;
|
|
11
|
+
/** `?confirmSubscription=1`: back from Stripe. The subscription is reconciled here, inside the community. */
|
|
12
|
+
confirmSubscription?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The state of a community's home shell: the header (name, tier badge,
|
|
17
|
+
* Upgrade), the one upgrade sheet every locked item opens, the Membership
|
|
18
|
+
* menu, a welcome after joining, and reconciling a paid join or upgrade on the
|
|
19
|
+
* way back from Stripe, which used to happen on the account page.
|
|
20
|
+
*/
|
|
21
|
+
export function useCommunityHome(opts: UseCommunityHomeOptions = {}) {
|
|
22
|
+
const context = useOptionalCommunityContext();
|
|
23
|
+
if (!context) throw new MissingCommunityProviderError("useCommunityHome()");
|
|
24
|
+
const community = useCommunity();
|
|
25
|
+
const confirmLatest = useConfirmLatestSubscription();
|
|
26
|
+
const invalidate = useInvalidateCommunities();
|
|
27
|
+
|
|
28
|
+
const [welcomeVisible, setWelcomeVisible] = useState(!!opts.welcome);
|
|
29
|
+
const [upgradeOpen, setUpgradeOpen] = useState(false);
|
|
30
|
+
const [upgradeTierId, setUpgradeTierId] = useState<string | null>(null);
|
|
31
|
+
const [membershipOpen, setMembershipOpen] = useState(false);
|
|
32
|
+
|
|
33
|
+
// Reconcile once per arrival, as soon as the community id is known.
|
|
34
|
+
//
|
|
35
|
+
// `confirming` is this component's own state, NOT `confirmLatest.isPending`.
|
|
36
|
+
// A mutation started from an effect loses its observer when React StrictMode
|
|
37
|
+
// unmounts and remounts the component in development, so `isPending` stayed
|
|
38
|
+
// true for ever and the home was stuck on "Confirming" although the request
|
|
39
|
+
// had answered. The promise itself always settles, so state set from it does.
|
|
40
|
+
const confirmed = useRef(false);
|
|
41
|
+
const [confirming, setConfirming] = useState(false);
|
|
42
|
+
const communityId = community.communityId ?? context.communityId;
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!opts.confirmSubscription || confirmed.current || !communityId) return;
|
|
45
|
+
confirmed.current = true;
|
|
46
|
+
setConfirming(true);
|
|
47
|
+
confirmLatest
|
|
48
|
+
.mutateAsync({ communityId })
|
|
49
|
+
.catch(() => undefined)
|
|
50
|
+
.finally(() => {
|
|
51
|
+
setConfirming(false);
|
|
52
|
+
void invalidate();
|
|
53
|
+
});
|
|
54
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
55
|
+
}, [opts.confirmSubscription, communityId]);
|
|
56
|
+
|
|
57
|
+
const opener = useMemo(
|
|
58
|
+
() => ({
|
|
59
|
+
openUpgrade: (tierId?: string | null) => {
|
|
60
|
+
setUpgradeTierId(tierId ?? null);
|
|
61
|
+
setUpgradeOpen(true);
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
[],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
...community,
|
|
69
|
+
/** Show the one-time welcome after a join. */
|
|
70
|
+
welcomeVisible: welcomeVisible && community.isMember,
|
|
71
|
+
dismissWelcome: () => setWelcomeVisible(false),
|
|
72
|
+
/** True while a Stripe return is being reconciled. */
|
|
73
|
+
confirming,
|
|
74
|
+
/** The header's Upgrade button shows only when a higher tier exists. */
|
|
75
|
+
showUpgrade: community.isMember && community.hasUpgrade,
|
|
76
|
+
/** The header's Join link, for a visitor who is not a member. */
|
|
77
|
+
showJoin: !community.isLoading && !community.isMember && !community.isPending,
|
|
78
|
+
joinPath: community.slug ? communityAboutPath(community.slug) : "",
|
|
79
|
+
upgradeOpen,
|
|
80
|
+
upgradeTierId,
|
|
81
|
+
/** Hand to `CommunityUpgradeOpenerProvider` so every locked item opens the same sheet. */
|
|
82
|
+
opener,
|
|
83
|
+
openUpgrade: opener.openUpgrade,
|
|
84
|
+
setUpgradeOpen,
|
|
85
|
+
membershipOpen,
|
|
86
|
+
setMembershipOpen,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useContext, useMemo } from "react";
|
|
2
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
3
|
+
import { MissingCommunityProviderError, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
|
|
4
|
+
import {
|
|
5
|
+
useCommunityInviteState,
|
|
6
|
+
type CommunityInviteKind,
|
|
7
|
+
type CommunityInviteState,
|
|
8
|
+
type CommunityInviteStateName,
|
|
9
|
+
type PublicTier,
|
|
10
|
+
} from "../../../data/queries/useCommunities";
|
|
11
|
+
|
|
12
|
+
/** Why an invite cannot be used. The About page says so and falls back to the normal Join. */
|
|
13
|
+
export type CommunityInviteProblem = "expired" | "revoked" | "used" | "used_up" | "not_found";
|
|
14
|
+
|
|
15
|
+
export interface CommunityInviteSummary {
|
|
16
|
+
/** `email` for a per-email invite (`?invite=`), `link` for a shareable one (`?join=`). Null with no token. */
|
|
17
|
+
kind: CommunityInviteKind | null;
|
|
18
|
+
/** The server's answer, adjusted for who is signed in right now. Null with no token or while loading. */
|
|
19
|
+
state: CommunityInviteStateName | null;
|
|
20
|
+
/** The invite can be accepted by this viewer as they are. */
|
|
21
|
+
isValid: boolean;
|
|
22
|
+
/** A per-email invite for a different address than the signed-in one. */
|
|
23
|
+
emailMismatch: boolean;
|
|
24
|
+
/** Set when the token is dead, and why. */
|
|
25
|
+
problem: CommunityInviteProblem | null;
|
|
26
|
+
/** The address a per-email invite was sent to: prefilled and locked in the join sheet. */
|
|
27
|
+
invitedEmail: string | null;
|
|
28
|
+
/** The tier the invite opens. Null for a dead token. */
|
|
29
|
+
tier: PublicTier | null;
|
|
30
|
+
/**
|
|
31
|
+
* The tokens to forward with the join. Only a usable token is forwarded: a
|
|
32
|
+
* dead one would turn the fallback Join into a refusal.
|
|
33
|
+
*/
|
|
34
|
+
forward: { inviteToken?: string; joinToken?: string };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEAD: ReadonlyArray<CommunityInviteStateName> = ["expired", "revoked", "used", "used_up", "not_found"];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What an invite is worth to this viewer. Pure, so the same rules drive the
|
|
41
|
+
* hosted sheet and a custom one.
|
|
42
|
+
*
|
|
43
|
+
* The server decides validity; this only adds what the server cannot know at
|
|
44
|
+
* read time: a fan who signed in on this page with a different address than
|
|
45
|
+
* the invite's is a mismatch now, even if the page loaded anonymous. A shareable
|
|
46
|
+
* link has no email lock, so it never mismatches.
|
|
47
|
+
*/
|
|
48
|
+
export function describeCommunityInvite(input: {
|
|
49
|
+
tokens: { invite?: string | null; join?: string | null };
|
|
50
|
+
answer: CommunityInviteState | null | undefined;
|
|
51
|
+
signedInEmail?: string | null;
|
|
52
|
+
}): CommunityInviteSummary {
|
|
53
|
+
const { tokens, answer } = input;
|
|
54
|
+
const kind: CommunityInviteKind | null = tokens.invite ? "email" : tokens.join ? "link" : null;
|
|
55
|
+
if (!kind || !answer) {
|
|
56
|
+
return { kind, state: null, isValid: false, emailMismatch: false, problem: null, invitedEmail: null, tier: null, forward: {} };
|
|
57
|
+
}
|
|
58
|
+
let state = answer.state;
|
|
59
|
+
const invitedEmail = answer.invitedEmail;
|
|
60
|
+
if (
|
|
61
|
+
state === "valid" &&
|
|
62
|
+
answer.kind === "email" &&
|
|
63
|
+
invitedEmail &&
|
|
64
|
+
input.signedInEmail &&
|
|
65
|
+
input.signedInEmail.trim().toLowerCase() !== invitedEmail.trim().toLowerCase()
|
|
66
|
+
) {
|
|
67
|
+
state = "email_mismatch";
|
|
68
|
+
}
|
|
69
|
+
const problem = DEAD.includes(state) ? (state as CommunityInviteProblem) : null;
|
|
70
|
+
// A mismatched invite is still forwarded: signing in as the invited address
|
|
71
|
+
// is the way through, and that sign-in happens in the same sheet.
|
|
72
|
+
const usable = state === "valid" || state === "email_mismatch";
|
|
73
|
+
return {
|
|
74
|
+
kind: answer.kind ?? kind,
|
|
75
|
+
state,
|
|
76
|
+
isValid: state === "valid",
|
|
77
|
+
emailMismatch: state === "email_mismatch",
|
|
78
|
+
problem,
|
|
79
|
+
invitedEmail: answer.kind === "email" ? invitedEmail : null,
|
|
80
|
+
tier: usable ? answer.tier : null,
|
|
81
|
+
forward: usable
|
|
82
|
+
? kind === "email"
|
|
83
|
+
? { inviteToken: tokens.invite! }
|
|
84
|
+
: { joinToken: tokens.join! }
|
|
85
|
+
: {},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The invite state for the surrounding community (`valid`, `expired`,
|
|
91
|
+
* `revoked`, `used`, `used_up`, `email_mismatch`, `not_found`), the invited
|
|
92
|
+
* email and the tier it opens. Pass `slug` to read a community other than the
|
|
93
|
+
* provider's.
|
|
94
|
+
*/
|
|
95
|
+
export function useCommunityInvite(opts: { invite?: string | null; join?: string | null; slug?: string } = {}) {
|
|
96
|
+
const context = useOptionalCommunityContext();
|
|
97
|
+
const ref = opts.slug ?? context?.ref;
|
|
98
|
+
if (!ref) throw new MissingCommunityProviderError("useCommunityInvite()");
|
|
99
|
+
const tokens = { invite: opts.invite ?? null, join: opts.invite ? null : (opts.join ?? null) };
|
|
100
|
+
const signedInEmail = useContext(PublicAuthContext)?.user?.email ?? null;
|
|
101
|
+
const query = useCommunityInviteState(ref, tokens);
|
|
102
|
+
const summary = useMemo(
|
|
103
|
+
() => describeCommunityInvite({ tokens, answer: query.data ?? (query.error ? NOT_FOUND(tokens) : undefined), signedInEmail }),
|
|
104
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
105
|
+
[tokens.invite, tokens.join, query.data, query.error, signedInEmail],
|
|
106
|
+
);
|
|
107
|
+
return { ...summary, isLoading: !!(tokens.invite || tokens.join) && query.isLoading };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A token the server could not resolve at all (a 404 or a failed read) reads as not found, never as valid. */
|
|
111
|
+
const NOT_FOUND = (tokens: { invite?: string | null; join?: string | null }): CommunityInviteState => ({
|
|
112
|
+
kind: tokens.invite ? "email" : "link",
|
|
113
|
+
state: "not_found",
|
|
114
|
+
invitedEmail: null,
|
|
115
|
+
tier: null,
|
|
116
|
+
});
|