@tribe-nest/forge 3.58.0 → 3.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/package.json +1 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CommunityContext.tsx +192 -0
  4. package/src/data/queries/useChat.ts +42 -39
  5. package/src/data/queries/useCommunities.ts +343 -0
  6. package/src/data/queries/useCommunity.ts +93 -89
  7. package/src/data/queries/useMembership.ts +32 -7
  8. package/src/data/queries/useMembershipCalls.ts +116 -7
  9. package/src/data/queries/usePostCollections.ts +28 -16
  10. package/src/data/queries/usePosts.ts +16 -14
  11. package/src/data/queries/useSubscriptions.ts +22 -8
  12. package/src/i18n/de.json +145 -1
  13. package/src/i18n/en.json +145 -1
  14. package/src/index.ts +19 -7
  15. package/src/runtime/pages.spec.ts +31 -0
  16. package/src/runtime/pages.ts +21 -22
  17. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  18. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  19. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  20. package/src/ui/format/membershipGate.ts +19 -15
  21. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  22. package/src/ui/headless/_tests/communityHarness.tsx +205 -0
  23. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  24. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  25. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +382 -0
  26. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +151 -0
  27. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +286 -0
  28. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  29. package/src/ui/headless/auth/useSignupForm.ts +10 -2
  30. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  31. package/src/ui/headless/communities/index.ts +55 -0
  32. package/src/ui/headless/communities/paths.ts +36 -0
  33. package/src/ui/headless/communities/useCommunity.ts +140 -0
  34. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  35. package/src/ui/headless/communities/useCommunityHome.ts +88 -0
  36. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  37. package/src/ui/headless/communities/useCommunityJoin.ts +444 -0
  38. package/src/ui/headless/communities/useCommunityMembership.ts +136 -0
  39. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  40. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  41. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  42. package/src/ui/headless/index.ts +8 -4
  43. package/src/ui/headless/memberHome/index.ts +1 -1
  44. package/src/ui/headless/memberHome/useMemberHomeNav.ts +44 -42
  45. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  46. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  47. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  48. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  49. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  50. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  51. package/src/ui/headless/posts/upsell.ts +54 -8
  52. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  53. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  54. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  55. package/src/ui/headless/posts/usePostItem.ts +6 -5
  56. package/src/ui/index.ts +17 -12
  57. package/src/ui/styled/AccountDashboard.tsx +48 -256
  58. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  59. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  60. package/src/ui/styled/MembershipTiers.tsx +49 -11
  61. package/src/ui/styled/Paywall.tsx +1 -1
  62. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +1 -1
  63. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  64. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  65. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  66. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  67. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  68. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  69. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  70. package/src/ui/styled/communities/CommunityAbout.tsx +140 -0
  71. package/src/ui/styled/communities/CommunityHome.tsx +175 -0
  72. package/src/ui/styled/communities/CommunityJoinSheet.tsx +444 -0
  73. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +133 -0
  74. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  75. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  76. package/src/ui/styled/communities/MyMembershipsList.tsx +89 -0
  77. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  78. package/src/ui/styled/communities/index.ts +10 -0
  79. package/src/ui/styled/communities/shared.tsx +149 -0
  80. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  81. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  82. package/src/ui/styled/forge-utilities.css +19 -0
  83. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  84. package/src/ui/styled/members/MemberCallsCalendar.tsx +142 -10
  85. package/src/ui/styled/members/MemberHomeLayout.tsx +35 -17
  86. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  87. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  88. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  89. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  90. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  91. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  92. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  93. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  94. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
@@ -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
+ });