@tribe-nest/forge 3.58.0 → 3.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/_tests/testCommunity.tsx +28 -0
- package/src/contexts/CommunityContext.tsx +192 -0
- package/src/data/queries/useChat.ts +42 -39
- package/src/data/queries/useCommunities.ts +343 -0
- package/src/data/queries/useCommunity.ts +93 -89
- package/src/data/queries/useMembership.ts +32 -7
- package/src/data/queries/useMembershipCalls.ts +116 -7
- package/src/data/queries/usePostCollections.ts +28 -16
- package/src/data/queries/usePosts.ts +16 -14
- package/src/data/queries/useSubscriptions.ts +22 -8
- package/src/i18n/de.json +145 -1
- package/src/i18n/en.json +145 -1
- package/src/index.ts +19 -7
- package/src/runtime/pages.spec.ts +31 -0
- package/src/runtime/pages.ts +21 -22
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
- package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
- package/src/ui/format/membershipGate.ts +19 -15
- package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
- package/src/ui/headless/_tests/communityHarness.tsx +205 -0
- package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
- package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +382 -0
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +151 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +286 -0
- package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
- package/src/ui/headless/auth/useSignupForm.ts +10 -2
- package/src/ui/headless/communities/CommunityGate.tsx +67 -0
- package/src/ui/headless/communities/index.ts +55 -0
- package/src/ui/headless/communities/paths.ts +36 -0
- package/src/ui/headless/communities/useCommunity.ts +140 -0
- package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
- package/src/ui/headless/communities/useCommunityHome.ts +88 -0
- package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +444 -0
- package/src/ui/headless/communities/useCommunityMembership.ts +136 -0
- package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
- package/src/ui/headless/communities/useMyMemberships.ts +59 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
- package/src/ui/headless/index.ts +8 -4
- package/src/ui/headless/memberHome/index.ts +1 -1
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +44 -42
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
- package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
- package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
- package/src/ui/headless/posts/upsell.ts +54 -8
- package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
- package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
- package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
- package/src/ui/headless/posts/usePostItem.ts +6 -5
- package/src/ui/index.ts +17 -12
- package/src/ui/styled/AccountDashboard.tsx +48 -256
- package/src/ui/styled/MembershipCheckout.tsx +37 -17
- package/src/ui/styled/MembershipTierCallout.tsx +20 -1
- package/src/ui/styled/MembershipTiers.tsx +49 -11
- package/src/ui/styled/Paywall.tsx +1 -1
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +1 -1
- package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
- package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
- package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
- package/src/ui/styled/communities/CommunityAbout.tsx +140 -0
- package/src/ui/styled/communities/CommunityHome.tsx +175 -0
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +444 -0
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +133 -0
- package/src/ui/styled/communities/CommunityPending.tsx +49 -0
- package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +89 -0
- package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
- package/src/ui/styled/communities/index.ts +10 -0
- package/src/ui/styled/communities/shared.tsx +149 -0
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
- package/src/ui/styled/forge-utilities.css +19 -0
- package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +142 -10
- package/src/ui/styled/members/MemberHomeLayout.tsx +35 -17
- package/src/ui/styled/members/MemberPostCard.tsx +9 -5
- package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
- package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
- package/src/ui/styled/members/callTranscriptText.ts +67 -0
- package/src/utils/_tests/safeRedirect.spec.ts +2 -2
- package/src/ui/headless/membership/MembershipGate.tsx +0 -44
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
import { MissingCommunityProviderError, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
|
|
3
|
+
import {
|
|
4
|
+
useCommunityUpgradePreview,
|
|
5
|
+
useInvalidateCommunities,
|
|
6
|
+
useUpgradeCommunityTier,
|
|
7
|
+
type CommunityUpgradeResult,
|
|
8
|
+
type CommunityUpgradeTier,
|
|
9
|
+
type PublicTier,
|
|
10
|
+
} from "../../../data/queries/useCommunities";
|
|
11
|
+
import {
|
|
12
|
+
hasPaystackSession,
|
|
13
|
+
openPaystackCheckout as openPaystackModal,
|
|
14
|
+
type PaystackCheckoutOutcome,
|
|
15
|
+
type PaystackCheckoutSession,
|
|
16
|
+
} from "../../../utils/paystackCheckout";
|
|
17
|
+
import { useCommunity } from "./useCommunity";
|
|
18
|
+
import { communityHomePath } from "./paths";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where the upgrade sheet is.
|
|
22
|
+
*
|
|
23
|
+
* choose the tiers above the current one
|
|
24
|
+
* confirm one tier chosen: its price now (prorated on Stripe) or the
|
|
25
|
+
* warning that the card is collected again (Paystack)
|
|
26
|
+
* submitting the upgrade request is in flight
|
|
27
|
+
* payment the provider needs the card: a Stripe secret to confirm, or a
|
|
28
|
+
* Paystack checkout to open
|
|
29
|
+
* done upgraded
|
|
30
|
+
*/
|
|
31
|
+
export type CommunityUpgradeStep = "choose" | "confirm" | "submitting" | "payment" | "done";
|
|
32
|
+
|
|
33
|
+
export interface UseCommunityUpgradeOptions {
|
|
34
|
+
/** Where the provider returns after a card payment. Default: the community home with `confirmSubscription=1`. */
|
|
35
|
+
returnPath?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Called once the upgrade is complete. Omitted, the fan is sent to the
|
|
38
|
+
* community home (`navigate`, or a full-page visit).
|
|
39
|
+
*/
|
|
40
|
+
onUpgraded?: (result: { requiresConfirmation: boolean; path: string }) => void;
|
|
41
|
+
/** The host's router, used for the default landing. */
|
|
42
|
+
navigate?: (href: string) => void;
|
|
43
|
+
/** Open with this tier chosen (a locked item's "Unlock with {tier}"). */
|
|
44
|
+
initialTierId?: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const errMessage = (e: unknown) =>
|
|
48
|
+
(e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Something went wrong.";
|
|
49
|
+
|
|
50
|
+
export interface CommunityUpgradeOption extends CommunityUpgradeTier {
|
|
51
|
+
/** The full tier (benefits, description) when the community read carried it. */
|
|
52
|
+
tier: PublicTier | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The upgrade sheet: only the tiers ABOVE the viewer's current one in this
|
|
57
|
+
* community, the price of moving now, and `upgrade(tierId)`.
|
|
58
|
+
*
|
|
59
|
+
* Provider differences live here, not in markup:
|
|
60
|
+
*
|
|
61
|
+
* - **Stripe**: one tap on the saved card. The server swaps the price with
|
|
62
|
+
* proration and the answer is `changed`, with nothing to pay here. If the
|
|
63
|
+
* bank asks for authentication, the answer is `requires_payment` with a
|
|
64
|
+
* secret to confirm.
|
|
65
|
+
* - **Paystack**: the provider cannot swap a price, so the subscription is
|
|
66
|
+
* cancelled and recreated and the card is collected again (decision 10).
|
|
67
|
+
* `requiresCard` is true from the preview, so the sheet says so BEFORE the fan
|
|
68
|
+
* confirms, and the answer carries a checkout to open.
|
|
69
|
+
*/
|
|
70
|
+
export function useCommunityUpgrade(opts: UseCommunityUpgradeOptions = {}) {
|
|
71
|
+
const context = useOptionalCommunityContext();
|
|
72
|
+
if (!context) throw new MissingCommunityProviderError("useCommunityUpgrade()");
|
|
73
|
+
const community = useCommunity();
|
|
74
|
+
const ref = community.slug ?? context.slug ?? context.ref;
|
|
75
|
+
const slug = community.slug ?? context.slug;
|
|
76
|
+
const mutation = useUpgradeCommunityTier(ref);
|
|
77
|
+
const invalidate = useInvalidateCommunities();
|
|
78
|
+
|
|
79
|
+
const [selectedTierId, setSelectedTierId] = useState<string | null>(opts.initialTierId ?? null);
|
|
80
|
+
const [result, setResult] = useState<CommunityUpgradeResult | null>(null);
|
|
81
|
+
const [paystackSession, setPaystackSession] = useState<PaystackCheckoutSession | null>(null);
|
|
82
|
+
const [done, setDone] = useState(false);
|
|
83
|
+
const [error, setError] = useState<string | null>(null);
|
|
84
|
+
|
|
85
|
+
const preview = useCommunityUpgradePreview(ref, selectedTierId ?? undefined);
|
|
86
|
+
|
|
87
|
+
const options: CommunityUpgradeOption[] = useMemo(
|
|
88
|
+
() =>
|
|
89
|
+
community.upgradeTiers.map((option) => ({
|
|
90
|
+
...option,
|
|
91
|
+
tier: community.tiers.find((tier) => tier.id === option.id) ?? null,
|
|
92
|
+
})),
|
|
93
|
+
[community.upgradeTiers, community.tiers],
|
|
94
|
+
);
|
|
95
|
+
const selected = options.find((option) => option.id === selectedTierId) ?? null;
|
|
96
|
+
|
|
97
|
+
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
98
|
+
const returnPath = opts.returnPath ?? (slug ? communityHomePath(slug, { confirmSubscription: 1 }) : "");
|
|
99
|
+
const landingPath = slug ? communityHomePath(slug) : returnPath;
|
|
100
|
+
|
|
101
|
+
const land = useCallback(
|
|
102
|
+
(requiresConfirmation: boolean) => {
|
|
103
|
+
setDone(true);
|
|
104
|
+
void invalidate();
|
|
105
|
+
const path = requiresConfirmation ? returnPath : landingPath;
|
|
106
|
+
if (opts.onUpgraded) return opts.onUpgraded({ requiresConfirmation, path });
|
|
107
|
+
if (opts.navigate) return opts.navigate(path);
|
|
108
|
+
if (typeof window !== "undefined" && path) window.location.assign(`${origin}${path}`);
|
|
109
|
+
},
|
|
110
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
111
|
+
[returnPath, landingPath, origin, opts.onUpgraded, opts.navigate],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const upgrade = async (tierId?: string) => {
|
|
115
|
+
const target = tierId ?? selectedTierId;
|
|
116
|
+
setError(null);
|
|
117
|
+
if (!target) {
|
|
118
|
+
setError("Choose a tier to upgrade to.");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
setSelectedTierId(target);
|
|
122
|
+
try {
|
|
123
|
+
const data = await mutation.mutateAsync({ membershipTierId: target, returnUrl: `${origin}${returnPath}` });
|
|
124
|
+
setResult(data);
|
|
125
|
+
if (data.status === "changed") {
|
|
126
|
+
land(false);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (data.accessCode || data.checkoutUrl) {
|
|
130
|
+
const session: PaystackCheckoutSession = {
|
|
131
|
+
accessCode: data.accessCode,
|
|
132
|
+
checkoutUrl: data.checkoutUrl,
|
|
133
|
+
returnUrl: `${origin}${returnPath}`,
|
|
134
|
+
};
|
|
135
|
+
setPaystackSession(session);
|
|
136
|
+
void openPaystackModal(session, {
|
|
137
|
+
onDismiss: () => {},
|
|
138
|
+
onError: (message) => setError(message ?? "The payment could not be completed. Please try again."),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
} catch (e) {
|
|
142
|
+
setError(errMessage(e));
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const step: CommunityUpgradeStep = done
|
|
147
|
+
? "done"
|
|
148
|
+
: mutation.isPending
|
|
149
|
+
? "submitting"
|
|
150
|
+
: result?.status === "requires_payment" && (result.clientSecret || hasPaystackSession(paystackSession))
|
|
151
|
+
? "payment"
|
|
152
|
+
: selectedTierId
|
|
153
|
+
? "confirm"
|
|
154
|
+
: "choose";
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
step,
|
|
158
|
+
isLoading: community.isLoading,
|
|
159
|
+
/** Only the tiers above the current one. Empty means there is nothing to upgrade to. */
|
|
160
|
+
options,
|
|
161
|
+
currentTier: community.currentTier,
|
|
162
|
+
currentTierName: community.membership?.tierName ?? null,
|
|
163
|
+
selectedTierId,
|
|
164
|
+
selected,
|
|
165
|
+
/** Pick a tier (moves to `confirm`). */
|
|
166
|
+
select: (tierId: string) => {
|
|
167
|
+
setError(null);
|
|
168
|
+
setResult(null);
|
|
169
|
+
setPaystackSession(null);
|
|
170
|
+
setSelectedTierId(tierId);
|
|
171
|
+
},
|
|
172
|
+
/** Back to the list. */
|
|
173
|
+
back: () => {
|
|
174
|
+
setError(null);
|
|
175
|
+
setResult(null);
|
|
176
|
+
setPaystackSession(null);
|
|
177
|
+
setSelectedTierId(null);
|
|
178
|
+
},
|
|
179
|
+
/** What moving now costs, once a tier is chosen. */
|
|
180
|
+
preview: preview.data ?? null,
|
|
181
|
+
previewLoading: !!selectedTierId && preview.isLoading,
|
|
182
|
+
/** True on Paystack: the card is collected again and the subscription replaced. Say so before confirming. */
|
|
183
|
+
requiresCard: preview.data?.requiresCard ?? false,
|
|
184
|
+
prorated: preview.data?.prorated ?? false,
|
|
185
|
+
upgrade,
|
|
186
|
+
isSubmitting: mutation.isPending,
|
|
187
|
+
error,
|
|
188
|
+
/** STRIPE: the secret to confirm on the payment step. */
|
|
189
|
+
clientSecret: result?.clientSecret,
|
|
190
|
+
/** PAYSTACK: the upgrade's checkout is open or can be re-opened. */
|
|
191
|
+
isPaystack: !!paystackSession,
|
|
192
|
+
canOpenPaystack: hasPaystackSession(paystackSession),
|
|
193
|
+
openPaystackCheckout: (): Promise<PaystackCheckoutOutcome> =>
|
|
194
|
+
paystackSession
|
|
195
|
+
? openPaystackModal(paystackSession, {
|
|
196
|
+
onDismiss: () => {},
|
|
197
|
+
onError: (message) => setError(message ?? "The payment could not be completed. Please try again."),
|
|
198
|
+
})
|
|
199
|
+
: Promise.resolve("unavailable" as PaystackCheckoutOutcome),
|
|
200
|
+
/** The renderer's `returnUrl` for the payment step. */
|
|
201
|
+
returnUrl: `${origin}${returnPath}`,
|
|
202
|
+
/** Call once the card renderer reports success. Lands on the community home to reconcile. */
|
|
203
|
+
onPaymentSucceeded: () => land(true),
|
|
204
|
+
/** Clear everything, e.g. when the sheet closes. */
|
|
205
|
+
reset: () => {
|
|
206
|
+
setError(null);
|
|
207
|
+
setResult(null);
|
|
208
|
+
setPaystackSession(null);
|
|
209
|
+
setSelectedTierId(null);
|
|
210
|
+
setDone(false);
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { useMyCommunityMemberships, type MyCommunityMembership } from "../../../data/queries/useCommunities";
|
|
3
|
+
import { getMembershipAccess, type MembershipAccessSummary } from "../../../utils/membershipAccess";
|
|
4
|
+
import { communityHomePath } from "./paths";
|
|
5
|
+
import { isMembershipInTrial } from "../membership/membershipTrial";
|
|
6
|
+
|
|
7
|
+
export interface MyMembershipEntry {
|
|
8
|
+
id: string;
|
|
9
|
+
community: { id: string; name: string; slug: string };
|
|
10
|
+
tierId: string | null;
|
|
11
|
+
tierName: string;
|
|
12
|
+
status: string;
|
|
13
|
+
access: MembershipAccessSummary;
|
|
14
|
+
/** When the paid period ends: a renewal, or the last day when cancelling. */
|
|
15
|
+
periodEnd: string | null;
|
|
16
|
+
cancelAtPeriodEnd: boolean;
|
|
17
|
+
pendingDowngradeTierId: string | null;
|
|
18
|
+
trialEndsAt: string | null;
|
|
19
|
+
/** Inside a free trial right now: nothing charged yet, the first charge on `trialEndsAt`. */
|
|
20
|
+
inTrial: boolean;
|
|
21
|
+
amount: number | null;
|
|
22
|
+
currency: string | null;
|
|
23
|
+
billingCycle: string | null;
|
|
24
|
+
/** The community home, where the fan manages this membership. */
|
|
25
|
+
homePath: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Normalise one `/public/memberships/mine` row. Pure, for the spec and for a custom list. */
|
|
29
|
+
export function toMyMembershipEntry(row: MyCommunityMembership): MyMembershipEntry {
|
|
30
|
+
const cancelAtPeriodEnd = !!row.cancelAtPeriodEnd;
|
|
31
|
+
return {
|
|
32
|
+
id: row.id,
|
|
33
|
+
community: row.community,
|
|
34
|
+
tierId: row.tierId ?? row.membershipTierId ?? row.membershipTier?.id ?? null,
|
|
35
|
+
tierName: row.tierName ?? row.membershipTier?.name ?? "",
|
|
36
|
+
status: row.status,
|
|
37
|
+
access: getMembershipAccess({ status: row.status, cancelAtPeriodEnd }),
|
|
38
|
+
periodEnd: row.currentPeriodEnd ?? row.endDate ?? null,
|
|
39
|
+
cancelAtPeriodEnd,
|
|
40
|
+
pendingDowngradeTierId: row.pendingDowngradeTierId ?? null,
|
|
41
|
+
trialEndsAt: row.trialEndsAt ?? null,
|
|
42
|
+
inTrial: isMembershipInTrial({ trialEndsAt: row.trialEndsAt ?? null }),
|
|
43
|
+
amount: row.subscriptionAmount ?? null,
|
|
44
|
+
currency: row.subscriptionCurrency ?? null,
|
|
45
|
+
billingCycle: row.billingCycle ?? null,
|
|
46
|
+
homePath: communityHomePath(row.community.slug),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Every membership the signed-in fan holds with this creator, one per
|
|
52
|
+
* community, for the account page's "My memberships". Nothing sends a fan
|
|
53
|
+
* here: managing a membership happens inside its community.
|
|
54
|
+
*/
|
|
55
|
+
export function useMyMemberships() {
|
|
56
|
+
const query = useMyCommunityMemberships();
|
|
57
|
+
const memberships = useMemo(() => (query.data ?? []).map(toMyMembershipEntry), [query.data]);
|
|
58
|
+
return { memberships, isLoading: query.isLoading, isError: query.isError, refetch: query.refetch };
|
|
59
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
2
|
import { useCallback, useRef, useState } from "react";
|
|
3
3
|
import { useForge } from "../../../provider/ForgeProvider";
|
|
4
|
+
import { useOptionalCommunityContext } from "../../../contexts/CommunityContext";
|
|
4
5
|
import { attachmentKind, type AttachmentKind } from "../chat/attachmentKind";
|
|
5
6
|
import type { CommunityAttachmentInput } from "../../../data/queries/useCommunity";
|
|
6
7
|
|
|
@@ -31,11 +32,14 @@ const toApiType = (kind: AttachmentKind): CommunityAttachmentInput["type"] => {
|
|
|
31
32
|
/**
|
|
32
33
|
* Headless attachment composer for community posts/replies: stage local files
|
|
33
34
|
* (with previews), upload to R2 via the member-scoped presigned endpoint
|
|
34
|
-
* (POST /public/community/attachments/presigned-url
|
|
35
|
+
* (POST /public/community/attachments/presigned-url: same flow as chat's
|
|
35
36
|
* useChatAttachmentUpload), and hand back `CommunityAttachmentInput[]`.
|
|
36
37
|
*/
|
|
37
38
|
export function useCommunityAttachmentUpload() {
|
|
38
39
|
const { client, profileId } = useForge();
|
|
40
|
+
// The community the attachment is posted into, when there is one. Soft: the
|
|
41
|
+
// composer also serves course spaces, which belong to no community.
|
|
42
|
+
const communityId = useOptionalCommunityContext()?.communityId;
|
|
39
43
|
const [pending, setPending] = useState<PendingCommunityAttachment[]>([]);
|
|
40
44
|
const [error, setError] = useState<string | null>(null);
|
|
41
45
|
const pendingRef = useRef<PendingCommunityAttachment[]>([]);
|
|
@@ -96,7 +100,7 @@ export function useCommunityAttachmentUpload() {
|
|
|
96
100
|
try {
|
|
97
101
|
const { data } = await client.post<{ presignedUrl: string; remoteUrl: string }>(
|
|
98
102
|
"/public/community/attachments/presigned-url",
|
|
99
|
-
{ fileName: item.file.name, profileId },
|
|
103
|
+
{ fileName: item.file.name, profileId, ...(communityId ? { communityId } : {}) },
|
|
100
104
|
);
|
|
101
105
|
await axios.put(data.presignedUrl, item.file, {
|
|
102
106
|
headers: { "Content-Type": item.file.type || "application/octet-stream" },
|
|
@@ -121,7 +125,7 @@ export function useCommunityAttachmentUpload() {
|
|
|
121
125
|
}
|
|
122
126
|
|
|
123
127
|
return uploaded;
|
|
124
|
-
}, [client, profileId, patch]);
|
|
128
|
+
}, [client, profileId, communityId, patch]);
|
|
125
129
|
|
|
126
130
|
return {
|
|
127
131
|
pending,
|
package/src/ui/headless/index.ts
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
export * from "./dialog";
|
|
4
4
|
export * from "./donation";
|
|
5
5
|
export * from "./offer";
|
|
6
|
-
|
|
6
|
+
// Communities: the provider every member-content hook reads, and the headless
|
|
7
|
+
// pages (list, About, join, invite, pending, upgrade, membership, gate).
|
|
8
|
+
// `CommunityGate` replaces the old `MembershipGate`, whose "any membership on
|
|
9
|
+
// the profile" rule is exactly what several communities make wrong.
|
|
10
|
+
export * from "./communities";
|
|
7
11
|
// S.4 members-only gates: the notice a storefront draws BEFORE the buyer pays.
|
|
8
12
|
export { useMembershipGateNotice, type UseMembershipGateNoticeInput } from "./membership/useMembershipGateNotice";
|
|
9
13
|
export { useEmailListForm, type UseEmailListFormOptions, type FormStatus } from "./forms/useEmailListForm";
|
|
@@ -27,7 +31,7 @@ export {
|
|
|
27
31
|
type CheckoutShippingData,
|
|
28
32
|
type SelectedShippingRate,
|
|
29
33
|
} from "./checkout/useCheckout";
|
|
30
|
-
// Inventory holds
|
|
34
|
+
// Inventory holds: the reservation a buyer is given while they pay, and the
|
|
31
35
|
// 409 they get when it lapses. Shared by both rendering stacks.
|
|
32
36
|
export { useInventoryHold, type InventoryHoldState, type UseInventoryHoldOptions } from "./checkout/useInventoryHold";
|
|
33
37
|
// Cart recovery: what a resume link does when the buyer clicks it. Shared so
|
|
@@ -58,7 +62,7 @@ export {
|
|
|
58
62
|
export { useLoginFlow, type LoginStep } from "./auth/useLoginFlow";
|
|
59
63
|
export { useSignupForm, type UseSignupFormOptions } from "./auth/useSignupForm";
|
|
60
64
|
|
|
61
|
-
// Flow primitives (Part A2)
|
|
65
|
+
// Flow primitives (Part A2): multi-step purchase/booking/subscribe/chat flows,
|
|
62
66
|
// each composed from the data hooks so a creator can rebuild any page.
|
|
63
67
|
export {
|
|
64
68
|
useEventCheckout,
|
|
@@ -196,7 +200,7 @@ export * from "./document";
|
|
|
196
200
|
export * from "./film";
|
|
197
201
|
// Work (project management) client-portal headless primitives.
|
|
198
202
|
export * from "./work";
|
|
199
|
-
// Funnel instrumentation
|
|
203
|
+
// Funnel instrumentation: wrap a multi-step flow to record step-through and
|
|
200
204
|
// drop-off on the first-party analytics feed.
|
|
201
205
|
export * from "./funnel";
|
|
202
206
|
export * from "./useVariantSelection";
|
|
@@ -3,29 +3,34 @@ import { useGetPostCollections } from "../../../data/queries/usePostCollections"
|
|
|
3
3
|
import { useCommunitySpaces } from "../../../data/queries/useCommunity";
|
|
4
4
|
import { useChatChannels } from "../../../data/queries/useChat";
|
|
5
5
|
import { useMyMembershipCalls } from "../../../data/queries/useMembershipCalls";
|
|
6
|
+
import { MissingCommunityProviderError, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
|
|
7
|
+
import { internalPath } from "../../../runtime/pages";
|
|
6
8
|
|
|
7
|
-
export type MemberHomeNavKey = "posts" | "collections" | "spaces" | "calendar" | "chat" | "
|
|
9
|
+
export type MemberHomeNavKey = "posts" | "collections" | "spaces" | "calendar" | "chat" | "leaderboard" | "notifications";
|
|
8
10
|
|
|
9
11
|
export interface MemberHomeNavItem {
|
|
10
12
|
key: MemberHomeNavKey;
|
|
11
13
|
label: string;
|
|
12
14
|
/** Absolute in-site href (already run through `buildHref` when supplied). */
|
|
13
15
|
href: string;
|
|
14
|
-
/** Conditional tabs (Collections/Spaces/Calendar/Chat)
|
|
16
|
+
/** Conditional tabs (Collections/Spaces/Calendar/Chat/Leaderboard/Notifications) hide until the community has content for them. */
|
|
15
17
|
visible: boolean;
|
|
16
|
-
/** Whether `currentPath` falls
|
|
18
|
+
/** Whether `currentPath` (or `activeKey`) falls on this tab. */
|
|
17
19
|
active: boolean;
|
|
18
|
-
/**
|
|
20
|
+
/** Kept for hosts that drew an out-of-home marker. Always false: every tab is inside the community. */
|
|
19
21
|
external: boolean;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export interface UseMemberHomeNavOptions {
|
|
23
25
|
/** Current location pathname, used to compute `active`. */
|
|
24
26
|
currentPath?: string;
|
|
25
|
-
/**
|
|
27
|
+
/** The active tab, when the host knows it (wins over `currentPath`). */
|
|
28
|
+
activeKey?: MemberHomeNavKey | null;
|
|
29
|
+
/**
|
|
30
|
+
* Member home base path. Default: the surrounding community's home,
|
|
31
|
+
* `/i/communities/<slug>/home`.
|
|
32
|
+
*/
|
|
26
33
|
basePath?: string;
|
|
27
|
-
/** "My membership" target. Client: `/account?tab=membership` (default). Site-starter: `/i/account?tab=membership`. */
|
|
28
|
-
membershipHref?: string;
|
|
29
34
|
/** Optional href transform (e.g. locale prefixes). Applied to every item. */
|
|
30
35
|
buildHref?: (href: string) => string;
|
|
31
36
|
}
|
|
@@ -36,17 +41,21 @@ const normalize = (p: string) => {
|
|
|
36
41
|
};
|
|
37
42
|
|
|
38
43
|
/**
|
|
39
|
-
* Headless nav model for
|
|
40
|
-
* visibility derives client-side from
|
|
41
|
-
* shared via React Query)
|
|
42
|
-
* the
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
44
|
+
* Headless nav model for a community's member home
|
|
45
|
+
* (`/i/communities/<slug>/home`). Tab visibility derives client-side from the
|
|
46
|
+
* queries the pages already need (cache shared via React Query), all scoped to
|
|
47
|
+
* the surrounding `<CommunityProvider>`: Posts is always visible; Collections
|
|
48
|
+
* when the community has any; Spaces (with its Leaderboard and Notifications)
|
|
49
|
+
* when any space is accessible; Chat when any channel is; Calendar when any
|
|
50
|
+
* call is. Billing, cancel and downgrade live in the community header's
|
|
51
|
+
* Membership menu, not here, and nothing links to the account page. Hosts own
|
|
52
|
+
* routing: pair with any layout via `href`/`active`.
|
|
46
53
|
*/
|
|
47
54
|
export function useMemberHomeNav(opts: UseMemberHomeNavOptions = {}) {
|
|
48
|
-
const
|
|
49
|
-
|
|
55
|
+
const community = useOptionalCommunityContext();
|
|
56
|
+
if (!community) throw new MissingCommunityProviderError("useMemberHomeNav()");
|
|
57
|
+
const slug = community.slug;
|
|
58
|
+
const basePath = opts.basePath ?? (slug ? internalPath("communities/$slug/home", { slug }) : "");
|
|
50
59
|
const buildHref = opts.buildHref ?? ((href: string) => href);
|
|
51
60
|
const currentPath = normalize(opts.currentPath ?? "");
|
|
52
61
|
|
|
@@ -54,21 +63,12 @@ export function useMemberHomeNav(opts: UseMemberHomeNavOptions = {}) {
|
|
|
54
63
|
const spacesQ = useCommunitySpaces();
|
|
55
64
|
const channelsQ = useChatChannels();
|
|
56
65
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
* ANY call this viewer may see, ever - not just an upcoming one.
|
|
64
|
-
*
|
|
65
|
-
* The Calendar is a month you can page backwards through, so it has something
|
|
66
|
-
* to show from the moment the creator has ever run a call. Keying the tab on
|
|
67
|
-
* upcoming calls alone hid the whole history the hour the last one ended,
|
|
68
|
-
* which is precisely when somebody goes looking for the recording.
|
|
69
|
-
*
|
|
70
|
-
* `limit: 1` because this is an existence check: the calendar fetches its own
|
|
71
|
-
* window, and the answer here is only "draw the tab or not".
|
|
66
|
+
* ANY call this viewer may see in this community, ever - not just an
|
|
67
|
+
* upcoming one. The Calendar is a month you can page backwards through, so
|
|
68
|
+
* it has something to show from the moment the creator has ever run a call.
|
|
69
|
+
* Keying the tab on upcoming calls alone hid the whole history the hour the
|
|
70
|
+
* last one ended, which is precisely when somebody goes looking for the
|
|
71
|
+
* recording. `limit: 1` because this is an existence check.
|
|
72
72
|
*/
|
|
73
73
|
const callsQ = useMyMembershipCalls({ from: new Date(0), limit: 1 });
|
|
74
74
|
|
|
@@ -80,27 +80,29 @@ export function useMemberHomeNav(opts: UseMemberHomeNavOptions = {}) {
|
|
|
80
80
|
const items: MemberHomeNavItem[] = useMemo(() => {
|
|
81
81
|
const isUnder = (segment: string) =>
|
|
82
82
|
currentPath === normalize(`${basePath}/${segment}`) || currentPath.startsWith(normalize(`${basePath}/${segment}`) + "/");
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
{ key: "
|
|
83
|
+
const onKey = (key: MemberHomeNavKey, byPath: boolean) => (opts.activeKey !== undefined ? opts.activeKey === key : byPath);
|
|
84
|
+
const defs: Array<Omit<MemberHomeNavItem, "href"> & { path: string }> = [
|
|
85
|
+
// The home itself is the posts feed; `home/posts` is the same tab.
|
|
86
|
+
{ key: "posts", label: "Posts", path: basePath, visible: true, external: false, active: onKey("posts", currentPath === normalize(basePath) || isUnder("posts")) },
|
|
87
|
+
{ key: "collections", label: "Collections", path: `${basePath}/collections`, visible: hasCollections, external: false, active: onKey("collections", isUnder("collections")) },
|
|
88
|
+
{ key: "spaces", label: "Spaces", path: `${basePath}/spaces`, visible: hasSpaces, external: false, active: onKey("spaces", isUnder("spaces") || isUnder("profile")) },
|
|
87
89
|
// Scheduled calls. Sits above Chat because a call has a TIME on it: it is
|
|
88
90
|
// the one tab whose content stops being useful if it is found late.
|
|
89
|
-
{ key: "calendar", label: "Calendar", path: `${basePath}/calendar`, visible: hasCalls, external: false, active: isUnder("calendar") },
|
|
90
|
-
{ key: "chat", label: "Chat", path: `${basePath}/chat`, visible: hasChannels, external: false, active: isUnder("chat") },
|
|
91
|
-
{ key: "
|
|
91
|
+
{ key: "calendar", label: "Calendar", path: `${basePath}/calendar`, visible: hasCalls, external: false, active: onKey("calendar", isUnder("calendar")) },
|
|
92
|
+
{ key: "chat", label: "Chat", path: `${basePath}/chat`, visible: hasChannels, external: false, active: onKey("chat", isUnder("chat")) },
|
|
93
|
+
{ key: "leaderboard", label: "Leaderboard", path: `${basePath}/leaderboard`, visible: hasSpaces, external: false, active: onKey("leaderboard", isUnder("leaderboard")) },
|
|
94
|
+
{ key: "notifications", label: "Notifications", path: `${basePath}/notifications`, visible: hasSpaces, external: false, active: onKey("notifications", isUnder("notifications")) },
|
|
92
95
|
];
|
|
93
96
|
return defs.map(({ path, ...d }) => ({ ...d, href: buildHref(path) }));
|
|
94
97
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
95
|
-
}, [basePath,
|
|
98
|
+
}, [basePath, currentPath, opts.activeKey, hasCollections, hasSpaces, hasCalls, hasChannels]);
|
|
96
99
|
|
|
97
100
|
return {
|
|
98
101
|
items,
|
|
99
|
-
/** Visible items only
|
|
102
|
+
/** Visible items only: what a shell actually renders. */
|
|
100
103
|
visibleItems: items.filter((i) => i.visible),
|
|
101
104
|
activeKey: items.find((i) => i.active)?.key ?? null,
|
|
102
105
|
/** True once every visibility query has settled (avoids tab flash). */
|
|
103
|
-
isReady:
|
|
104
|
-
!collectionsQ.isLoading && !spacesQ.isLoading && !callsQ.isLoading && !channelsQ.isLoading,
|
|
106
|
+
isReady: !!basePath && !collectionsQ.isLoading && !spacesQ.isLoading && !callsQ.isLoading && !channelsQ.isLoading,
|
|
105
107
|
};
|
|
106
108
|
}
|
|
@@ -43,6 +43,14 @@ vi.mock("../../../../data/queries/useMembership", async (importOriginal) => ({
|
|
|
43
43
|
useConfirmMembershipApplication: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
|
44
44
|
}));
|
|
45
45
|
|
|
46
|
+
// Outside a community there is no viewer state to read (the hook falls back to
|
|
47
|
+
// `user.membership`); stubbed like the other queries, since this spec renders
|
|
48
|
+
// without a Forge provider.
|
|
49
|
+
vi.mock("../../../../data/queries/useCommunities", async (importOriginal) => ({
|
|
50
|
+
...(await importOriginal<typeof import("../../../../data/queries/useCommunities")>()),
|
|
51
|
+
useCommunityViewerState: () => ({ data: undefined, isLoading: false }),
|
|
52
|
+
}));
|
|
53
|
+
|
|
46
54
|
vi.mock("../../../../data/queries/useSubscriptions", async (importOriginal) => ({
|
|
47
55
|
...(await importOriginal<typeof import("../../../../data/queries/useSubscriptions")>()),
|
|
48
56
|
useCreateSubscription: () => ({ mutateAsync: subscriptions.createPaid, isPending: false }),
|
|
@@ -87,7 +87,7 @@ beforeEach(() => {
|
|
|
87
87
|
api.createFree.mockResolvedValue({});
|
|
88
88
|
api.createPaid.mockReset();
|
|
89
89
|
auth.refetchUser.mockClear();
|
|
90
|
-
window.history.replaceState(null, "", "/i/
|
|
90
|
+
window.history.replaceState(null, "", "/i/communities/fan-club");
|
|
91
91
|
});
|
|
92
92
|
|
|
93
93
|
const onComplete = vi.fn();
|
|
@@ -214,13 +214,14 @@ describe("application tiers: subscribe() applies", () => {
|
|
|
214
214
|
|
|
215
215
|
describe("invite tokens", () => {
|
|
216
216
|
it("reads the invite off the url and forwards it to the tier list and the paid subscribe", async () => {
|
|
217
|
-
window.history.replaceState(null, "", "/i/
|
|
217
|
+
window.history.replaceState(null, "", "/i/communities/fan-club?tier=tier-1&invite=tok-abc");
|
|
218
218
|
tiers = [tier({ priceMonthly: 12, joinMode: "invite" })];
|
|
219
219
|
api.createPaid.mockResolvedValue({ requiresPayment: true, clientSecret: "pi_1_secret_2" });
|
|
220
220
|
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1" }));
|
|
221
221
|
|
|
222
222
|
expect(result.current.inviteToken).toBe("tok-abc");
|
|
223
|
-
|
|
223
|
+
// `enabled` is on because this checkout was handed no tier list of its own.
|
|
224
|
+
expect(api.tiersOptions.at(-1)).toEqual({ inviteToken: "tok-abc", enabled: true });
|
|
224
225
|
await act(async () => {
|
|
225
226
|
await result.current.subscribe();
|
|
226
227
|
});
|
|
@@ -247,7 +248,7 @@ describe("invite tokens", () => {
|
|
|
247
248
|
await result.current.subscribe();
|
|
248
249
|
});
|
|
249
250
|
|
|
250
|
-
expect(api.tiersOptions.at(-1)).toEqual({ inviteToken: null });
|
|
251
|
+
expect(api.tiersOptions.at(-1)).toEqual({ inviteToken: null, enabled: true });
|
|
251
252
|
expect(api.createFree.mock.calls[0][0]).not.toHaveProperty("inviteToken");
|
|
252
253
|
});
|
|
253
254
|
});
|
|
@@ -75,7 +75,7 @@ beforeEach(() => {
|
|
|
75
75
|
api.createFree.mockResolvedValue({});
|
|
76
76
|
api.createPaid.mockReset();
|
|
77
77
|
onComplete.mockClear();
|
|
78
|
-
window.history.replaceState(null, "", "/i/
|
|
78
|
+
window.history.replaceState(null, "", "/i/communities/fan-club");
|
|
79
79
|
});
|
|
80
80
|
|
|
81
81
|
function answerRequired(result: { current: ReturnType<typeof useMembershipCheckout> }) {
|
|
@@ -61,7 +61,7 @@ beforeEach(() => {
|
|
|
61
61
|
api.createPaid.mockReset();
|
|
62
62
|
onComplete.mockReset();
|
|
63
63
|
auth.user.membership = undefined;
|
|
64
|
-
window.history.replaceState(null, "", "/i/
|
|
64
|
+
window.history.replaceState(null, "", "/i/communities/fan-club");
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
describe("useMembershipCheckout: trial exposure", () => {
|