@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.
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,444 @@
1
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
3
+ import { MissingCommunityProviderError, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
4
+ import { useInvalidateCommunities, type PublicTier } from "../../../data/queries/useCommunities";
5
+ import { useMembershipCheckout } from "../membership/useMembershipCheckout";
6
+ import { useSignupForm } from "../auth/useSignupForm";
7
+ import { useLoginFlow } from "../auth/useLoginFlow";
8
+ import { offeredCycles } from "../../format/membershipPwyw";
9
+ import { useCommunity } from "./useCommunity";
10
+ import { useCommunityInvite } from "./useCommunityInvite";
11
+ import { communityHomePath } from "./paths";
12
+
13
+ /**
14
+ * Where the join sheet is. Steps that do not apply are skipped, never shown:
15
+ *
16
+ * email who the fan is: sign up (name, email, password) or sign in.
17
+ * Skipped when signed in.
18
+ * code the 6-digit code emailed to them. Skipped when signed in.
19
+ * tier which tier, and on a tier with a choice, the billing cycle or
20
+ * the pay-what-you-want amount. Skipped when there is one tier
21
+ * and nothing to choose.
22
+ * questions the tier's questions. Only when it has some.
23
+ * payment the card. Only on a paid tier (or a paid application, which
24
+ * saves the card and charges on approval).
25
+ * submitting a join request is in flight.
26
+ * joined in. The sheet lands the fan on the community home.
27
+ * pending an application was lodged: the fan lands inside, pending.
28
+ */
29
+ export type CommunityJoinStep = "email" | "code" | "tier" | "questions" | "payment" | "submitting" | "joined" | "pending";
30
+
31
+ /** The facts the step is derived from. Exported so the order can be tested without a provider tree. */
32
+ export interface CommunityJoinStepInput {
33
+ isAuthenticated: boolean;
34
+ awaitingCode: boolean;
35
+ /** A per-email invite for another address, while signed in: the sheet offers "Sign in as". */
36
+ emailMismatch: boolean;
37
+ joined: boolean;
38
+ pending: boolean;
39
+ /** Already a member here and not changing tier through this sheet. */
40
+ alreadyMember: boolean;
41
+ isProcessing: boolean;
42
+ hasPaymentStep: boolean;
43
+ hasSelectedTier: boolean;
44
+ /** The tier step still needs the fan (several tiers, or a choice on the one). */
45
+ tierStepOpen: boolean;
46
+ /** The tier's questions are waiting for answers. */
47
+ questionsOpen: boolean;
48
+ }
49
+
50
+ /** The join step, pure. The ORDER here is the flow: identity, then tier, questions, card. */
51
+ export function communityJoinStep(input: CommunityJoinStepInput): CommunityJoinStep {
52
+ if (input.joined || (input.alreadyMember && input.isAuthenticated)) return "joined";
53
+ if (input.pending) return "pending";
54
+ if (!input.isAuthenticated) return input.awaitingCode ? "code" : "email";
55
+ if (input.emailMismatch) return "email";
56
+ if (input.isProcessing) return "submitting";
57
+ if (input.hasPaymentStep) return "payment";
58
+ if (!input.hasSelectedTier || input.tierStepOpen) return "tier";
59
+ if (input.questionsOpen) return "questions";
60
+ return "submitting";
61
+ }
62
+
63
+ /** Does this tier ask the fan to choose anything (a cycle, or an amount)? */
64
+ export function tierHasChoice(tier: Pick<PublicTier, "payWhatYouWant" | "priceMonthly" | "priceYearly" | "payWhatYouWantMinimum" | "payWhatYouWantYearlyMinimum" | "payWhatYouWantMaximum" | "payWhatYouWantYearlyMaximum">): boolean {
65
+ if (tier.payWhatYouWant) return true;
66
+ const cycles = offeredCycles(tier);
67
+ return cycles.month && cycles.year;
68
+ }
69
+
70
+ export interface UseCommunityJoinOptions {
71
+ /** Preselect a tier (`?tier=` on the About page). An invite's tier wins over it. */
72
+ tierId?: string | null;
73
+ /** A per-email invite token (`?invite=`): its tier is preset and its email locked. */
74
+ invite?: string | null;
75
+ /** A shareable invite link token (`?join=`): its tier is preset, no email lock. */
76
+ join?: string | null;
77
+ /**
78
+ * Where Stripe returns after a paid join. Default: the community home with
79
+ * `confirmSubscription=1`, which reconciles the subscription there.
80
+ */
81
+ returnPath?: string;
82
+ /**
83
+ * Called when the join is complete or an application is lodged. Omitted, the
84
+ * fan is sent to the community home (with `welcome=1` after a join).
85
+ */
86
+ onJoined?: (result: { pending: boolean; requiresConfirmation: boolean; path: string }) => void;
87
+ /** The host's router, used for the default landing instead of a full-page visit. */
88
+ navigate?: (href: string) => void;
89
+ /** Require the terms checkbox on sign-up (default true). */
90
+ requireAcceptance?: boolean;
91
+ }
92
+
93
+ /**
94
+ * Join a community from its About page, in place: Skool-simple.
95
+ *
96
+ * email -> code -> tier -> questions -> payment -> inside
97
+ *
98
+ * with every step that does not apply skipped. A free tier with nothing to
99
+ * choose is joined the moment the code is accepted. Every success lands INSIDE
100
+ * the community (`/i/communities/<slug>/home`), never on the account page.
101
+ *
102
+ * Composes, rather than re-implements: the email-code sign-up (`useSignupForm`)
103
+ * and sign-in (`useLoginFlow`), the membership checkout (`useMembershipCheckout`:
104
+ * pricing, pay-what-you-want, questions, trials, free and paid subscribe,
105
+ * applications, Stripe and Paystack), the community read (`useCommunity`) and
106
+ * the invite read (`useCommunityInvite`).
107
+ *
108
+ * Invites:
109
+ * - a per-email invite presets its tier and prefills and LOCKS the email. A fan
110
+ * signed in as someone else gets `emailMismatch` and `signInAsInvited()`.
111
+ * - a shareable link presets its tier with no email lock.
112
+ * - a dead token (expired, revoked, used, used up, not found) is reported in
113
+ * `inviteProblem` and NOT forwarded, so the normal Join still works.
114
+ *
115
+ * The card step exposes what `ForgePaymentProvider`'s renderer needs:
116
+ * `clientSecret`, `paymentKind`, `returnUrl`, `amount`, `trial`, and
117
+ * `onPaymentSucceeded`; on Paystack, `openPaystackCheckout`.
118
+ */
119
+ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
120
+ const context = useOptionalCommunityContext();
121
+ if (!context) throw new MissingCommunityProviderError("useCommunityJoin()");
122
+ const auth = useContext(PublicAuthContext);
123
+ const user = auth?.user ?? null;
124
+ const isAuthenticated = !!user;
125
+
126
+ const inviteToken = opts.invite ?? null;
127
+ const joinToken = inviteToken ? null : (opts.join ?? null);
128
+ const invite = useCommunityInvite({ invite: inviteToken, join: joinToken });
129
+ // Only a usable token changes what is listed and what is sent.
130
+ const forwardInvite = invite.forward.inviteToken ?? null;
131
+ const forwardJoin = invite.forward.joinToken ?? null;
132
+ const community = useCommunity(undefined, { invite: forwardInvite, join: forwardJoin });
133
+ const invalidate = useInvalidateCommunities();
134
+
135
+ const slug = community.slug ?? context.slug;
136
+ const home = (query?: { welcome?: 1; confirmSubscription?: 1 }) => (slug ? communityHomePath(slug, query) : "");
137
+ const paidReturnPath = opts.returnPath ?? home({ confirmSubscription: 1 });
138
+
139
+ // ---- Tiers -------------------------------------------------------------------
140
+ const tiers = useMemo(() => {
141
+ const list = [...community.tiers];
142
+ // The invite's tier is joinable even when the public list hides it.
143
+ if (invite.tier && !list.some((tier) => tier.id === invite.tier!.id)) list.unshift(invite.tier);
144
+ return list;
145
+ }, [community.tiers, invite.tier]);
146
+ const presetTierId =
147
+ invite.tier?.id ?? (opts.tierId && tiers.some((tier) => tier.id === opts.tierId) ? opts.tierId : null) ??
148
+ (tiers.length === 1 ? tiers[0]!.id : null);
149
+
150
+ // ---- Landing -------------------------------------------------------------------
151
+ const [joined, setJoined] = useState(false);
152
+ const [landedPending, setLandedPending] = useState(false);
153
+ const land = useCallback(
154
+ (result: { pending: boolean; requiresConfirmation: boolean }) => {
155
+ void invalidate();
156
+ const path = result.pending ? home() : result.requiresConfirmation ? paidReturnPath : home({ welcome: 1 });
157
+ if (opts.onJoined) return opts.onJoined({ ...result, path });
158
+ if (opts.navigate) return opts.navigate(path);
159
+ if (typeof window !== "undefined" && path) window.location.assign(`${window.location.origin}${path}`);
160
+ },
161
+ // eslint-disable-next-line react-hooks/exhaustive-deps
162
+ [slug, paidReturnPath, opts.onJoined, opts.navigate],
163
+ );
164
+
165
+ const checkout = useMembershipCheckout({
166
+ tiers,
167
+ initialTierId: presetTierId ?? undefined,
168
+ inviteToken: forwardInvite,
169
+ joinToken: forwardJoin,
170
+ current: community.membership ? { tierId: community.membership.tierId, status: community.membership.status } : null,
171
+ returnPath: paidReturnPath,
172
+ freeReturnPath: home({ welcome: 1 }),
173
+ applicationReturnPath: home(),
174
+ onComplete: ({ requiresConfirmation }) => {
175
+ setJoined(true);
176
+ land({ pending: false, requiresConfirmation });
177
+ },
178
+ });
179
+
180
+ // The tier list can arrive after the first render (and an invite's tier after
181
+ // that): select the preset once it exists.
182
+ useEffect(() => {
183
+ if (!checkout.selectedTierId && presetTierId && tiers.some((tier) => tier.id === presetTierId)) {
184
+ checkout.setSelectedTierId(presetTierId);
185
+ checkout.setStep("select");
186
+ }
187
+ // eslint-disable-next-line react-hooks/exhaustive-deps
188
+ }, [presetTierId, tiers]);
189
+
190
+ // ---- Identity (email + code) -------------------------------------------------------
191
+ const [mode, setMode] = useState<"signup" | "signin">("signup");
192
+ const signup = useSignupForm({
193
+ membershipTierId: checkout.selectedTierId,
194
+ requireAcceptance: opts.requireAcceptance,
195
+ });
196
+ const login = useLoginFlow();
197
+ const lockedEmail = invite.kind === "email" && (invite.isValid || invite.emailMismatch) ? invite.invitedEmail : null;
198
+ useEffect(() => {
199
+ if (!lockedEmail) return;
200
+ if (signup.email !== lockedEmail) signup.setEmail(lockedEmail);
201
+ if (login.email !== lockedEmail) login.setEmail(lockedEmail);
202
+ // eslint-disable-next-line react-hooks/exhaustive-deps
203
+ }, [lockedEmail, signup.email, login.email]);
204
+ const awaitingCode = mode === "signup" ? signup.awaitingCode : login.step === "code";
205
+
206
+ // ---- Tier and question confirmation ------------------------------------------------
207
+ const [tierConfirmed, setTierConfirmed] = useState(false);
208
+ const [questionsConfirmed, setQuestionsConfirmed] = useState(false);
209
+ const [attempted, setAttempted] = useState(false);
210
+ const selectedTier = checkout.selectedTier as PublicTier | undefined;
211
+ // An invite names its tier, so the list is not offered; a choice ON the tier still is.
212
+ const offersTierList = tiers.length > 1 && !invite.tier;
213
+ const tierStepNeeded = offersTierList || (!!selectedTier && tierHasChoice(selectedTier));
214
+ // A join that was refused (the amount below the floor, a server error) comes
215
+ // back to the tier step, which owns the message and the retry.
216
+ const refused = attempted && !checkout.isProcessing && (!!checkout.error || !!checkout.amountRefusal);
217
+ const tierStepOpen = !selectedTier || (tierStepNeeded && !tierConfirmed) || refused;
218
+ const questionsOpen = checkout.questionnaire.length > 0 && !questionsConfirmed;
219
+
220
+ const isChangingTier = !!community.membership && !!selectedTier && selectedTier.id !== community.membership.tierId && (!!invite.tier || !!opts.tierId);
221
+ const alreadyMember = community.isMember && !isChangingTier;
222
+ const pending =
223
+ landedPending ||
224
+ checkout.applicationOutcome === "received" ||
225
+ checkout.applicationOutcome === "approved" ||
226
+ (community.isPending && !joined);
227
+
228
+ const hasPaymentStep = checkout.step === "payment" && (!!checkout.clientSecret || checkout.canOpenPaystack);
229
+
230
+ const step = communityJoinStep({
231
+ isAuthenticated,
232
+ awaitingCode,
233
+ emailMismatch: isAuthenticated && invite.emailMismatch,
234
+ joined,
235
+ pending,
236
+ alreadyMember,
237
+ isProcessing: checkout.isProcessing,
238
+ hasPaymentStep,
239
+ hasSelectedTier: !!selectedTier,
240
+ tierStepOpen,
241
+ questionsOpen,
242
+ });
243
+
244
+ // An application lodged: land inside, pending (decision 9).
245
+ useEffect(() => {
246
+ if (landedPending) return;
247
+ if (checkout.applicationOutcome === "received" || checkout.applicationOutcome === "approved") {
248
+ setLandedPending(true);
249
+ land({ pending: true, requiresConfirmation: false });
250
+ }
251
+ // eslint-disable-next-line react-hooks/exhaustive-deps
252
+ }, [checkout.applicationOutcome]);
253
+
254
+ const submit = useCallback(async () => {
255
+ setAttempted(true);
256
+ if (selectedTier) autoSubmitted.current = selectedTier.id;
257
+ await checkout.subscribe();
258
+ // A refusal (an amount below the floor, a blank required answer, a server
259
+ // error) leaves nothing started: reopen the step that owns the message.
260
+ if (checkout.questionnaire.length > 0) setQuestionsConfirmed(false);
261
+ // eslint-disable-next-line react-hooks/exhaustive-deps
262
+ }, [checkout.subscribe, checkout.questionnaire.length]);
263
+
264
+ // Nothing left to ask: join straight away. Once per tier, so a refusal does
265
+ // not loop; the fan retries from the tier step, which then owns the error.
266
+ const autoSubmitted = useRef<string | null>(null);
267
+ const viewerSettled = !community.viewerLoading && !community.isLoading;
268
+ useEffect(() => {
269
+ if (step !== "submitting" || checkout.isProcessing || !selectedTier || !viewerSettled) return;
270
+ if (autoSubmitted.current === selectedTier.id) return;
271
+ autoSubmitted.current = selectedTier.id;
272
+ void submit();
273
+ // eslint-disable-next-line react-hooks/exhaustive-deps
274
+ }, [step, checkout.isProcessing, selectedTier?.id, viewerSettled]);
275
+
276
+ // The already-member case lands straight inside, once.
277
+ const landedMember = useRef(false);
278
+ useEffect(() => {
279
+ if (step === "joined" && alreadyMember && !joined && !landedMember.current) {
280
+ landedMember.current = true;
281
+ if (slug) {
282
+ const path = home();
283
+ if (opts.onJoined) opts.onJoined({ pending: false, requiresConfirmation: false, path });
284
+ else if (opts.navigate) opts.navigate(path);
285
+ }
286
+ }
287
+ // eslint-disable-next-line react-hooks/exhaustive-deps
288
+ }, [step, alreadyMember, joined, slug]);
289
+
290
+ const identityError = mode === "signup" ? signup.error : login.error;
291
+ const identitySubmitting = mode === "signup" ? signup.isSubmitting : login.isSubmitting;
292
+
293
+ /** Advance from the current step. */
294
+ const next = async () => {
295
+ switch (step) {
296
+ case "email":
297
+ if (mode === "signup") await signup.submit();
298
+ else await login.submitCredentials();
299
+ return;
300
+ case "code":
301
+ if (mode === "signup") await signup.submitCode();
302
+ else await login.submitCode();
303
+ return;
304
+ case "tier":
305
+ if (!selectedTier) return;
306
+ setTierConfirmed(true);
307
+ // The auto-submit is spent on a retry: pressing the button is the retry.
308
+ autoSubmitted.current = selectedTier.id;
309
+ if (checkout.questionnaire.length > 0 && !questionsConfirmed) return;
310
+ await submit();
311
+ return;
312
+ case "questions":
313
+ if (selectedTier) autoSubmitted.current = selectedTier.id;
314
+ setQuestionsConfirmed(true);
315
+ await submit();
316
+ return;
317
+ default:
318
+ return;
319
+ }
320
+ };
321
+
322
+ /** Go back one step, where there is one to go back to. */
323
+ const back = () => {
324
+ switch (step) {
325
+ case "code":
326
+ if (mode === "signup") signup.editDetails();
327
+ else login.back();
328
+ return;
329
+ case "questions":
330
+ if (tierStepNeeded) setTierConfirmed(false);
331
+ return;
332
+ case "payment":
333
+ checkout.setStep("select");
334
+ if (checkout.questionnaire.length > 0) setQuestionsConfirmed(false);
335
+ else if (tierStepNeeded) setTierConfirmed(false);
336
+ autoSubmitted.current = selectedTier?.id ?? null;
337
+ return;
338
+ default:
339
+ return;
340
+ }
341
+ };
342
+
343
+ return {
344
+ step,
345
+ isLoading: community.isLoading || invite.isLoading,
346
+ community: community.community,
347
+ slug,
348
+ tiers,
349
+ viewer: community.viewer,
350
+ /** Already a member here: the sheet has nothing to do but go inside. */
351
+ alreadyMember,
352
+
353
+ // ---- email + code ----
354
+ /** `signup` (new fan) or `signin` (has an account). */
355
+ mode,
356
+ setMode,
357
+ /** The sign-up fields (name, email, password, terms). */
358
+ signup,
359
+ /** The sign-in fields (email, password, then code). */
360
+ login,
361
+ /** The email is set by a per-email invite and cannot be changed. */
362
+ emailLocked: !!lockedEmail,
363
+ lockedEmail,
364
+ /** The code the fan types on the `code` step, whichever mode. */
365
+ code: mode === "signup" ? signup.code : login.code,
366
+ setCode: (value: string) => (mode === "signup" ? signup.setCode(value) : login.setCode(value)),
367
+ resendCode: () => (mode === "signup" ? signup.resendCode() : login.resend()),
368
+
369
+ // ---- invites ----
370
+ invite,
371
+ /** Why the URL's invite cannot be used, if it cannot. The normal Join still works. */
372
+ inviteProblem: invite.problem,
373
+ /** Signed in as another address than a per-email invite's. */
374
+ emailMismatch: isAuthenticated && invite.emailMismatch ? { invitedEmail: invite.invitedEmail ?? "", signedInEmail: user?.email ?? "" } : null,
375
+ /** "Sign in as {email}": signs the current account out and opens sign-in with the invited address locked. */
376
+ signInAsInvited: async () => {
377
+ await auth?.logout();
378
+ setMode("signin");
379
+ },
380
+
381
+ // ---- tier ----
382
+ selectedTier,
383
+ selectTier: (tier: PublicTier) => {
384
+ checkout.selectTier(tier);
385
+ setTierConfirmed(false);
386
+ setQuestionsConfirmed(false);
387
+ autoSubmitted.current = null;
388
+ },
389
+ /** True when more than one tier is offered, so the step lists them. False on an invite, which names its tier. */
390
+ hasTierChoice: offersTierList,
391
+ billingCycle: checkout.billingCycle,
392
+ setBillingCycle: checkout.setBillingCycle,
393
+ cycles: checkout.cycles,
394
+ isFreeCycle: checkout.isFreeCycle,
395
+ customAmount: checkout.customAmount,
396
+ setCustomAmount: checkout.setCustomAmount,
397
+ minimumAmount: checkout.minimumAmount,
398
+ maximumAmount: checkout.maximumAmount,
399
+ amountRefusal: checkout.amountRefusal,
400
+ amount: checkout.amount,
401
+ trialDays: checkout.trialDays,
402
+ trialEndsAt: checkout.trialEndsAt,
403
+ isApplication: checkout.isApplication,
404
+ isChange: checkout.isChange,
405
+
406
+ // ---- questions ----
407
+ questionnaire: checkout.questionnaire,
408
+ questionnaireAnswers: checkout.questionnaireAnswers,
409
+ setQuestionnaireAnswer: checkout.setQuestionnaireAnswer,
410
+ questionnaireRefusal: checkout.questionnaireRefusal,
411
+
412
+ // ---- payment ----
413
+ clientSecret: checkout.clientSecret,
414
+ /** `setup` when the card is saved rather than charged (a trial, or an application). */
415
+ paymentKind: checkout.paymentKind,
416
+ setupPurpose: checkout.setupPurpose,
417
+ /** The renderer's `returnUrl`: the community home, reconciling the subscription there. */
418
+ returnUrl:
419
+ checkout.setupPurpose === "application" && checkout.applicationReturnUrl
420
+ ? checkout.applicationReturnUrl
421
+ : `${typeof window !== "undefined" ? window.location.origin : ""}${paidReturnPath}`,
422
+ trial:
423
+ checkout.setupPurpose === "trial" && checkout.trialEndsAt
424
+ ? { endsAt: checkout.trialEndsAt, billingCycle: checkout.billingCycle }
425
+ : null,
426
+ isPaystack: checkout.isPaystack,
427
+ canOpenPaystack: checkout.canOpenPaystack,
428
+ openPaystackCheckout: checkout.openPaystackCheckout,
429
+ onPaymentSucceeded: checkout.onPaymentSucceeded,
430
+
431
+ // ---- outcome ----
432
+ application: checkout.application,
433
+ applicationOutcome: checkout.applicationOutcome,
434
+ /** Whether a join was attempted in this sheet. */
435
+ attempted,
436
+ error: step === "email" || step === "code" ? identityError : checkout.error,
437
+ isSubmitting: identitySubmitting || checkout.isProcessing,
438
+
439
+ next,
440
+ back,
441
+ /** Submit the join now (the tier and questions as they stand). */
442
+ submit,
443
+ };
444
+ }
@@ -0,0 +1,136 @@
1
+ import { useMemo, useState } from "react";
2
+ import { MissingCommunityProviderError, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
3
+ import {
4
+ useCancelCommunityMembership,
5
+ useDowngradeCommunityMembership,
6
+ useUndoCommunityDowngrade,
7
+ type PublicTier,
8
+ } from "../../../data/queries/useCommunities";
9
+ import { useOpenBillingPortal } from "../../../data/queries/useSubscriptions";
10
+ import { cycleFloor, defaultCycle } from "../../format/membershipPwyw";
11
+ import { useCommunity } from "./useCommunity";
12
+
13
+ const errMessage = (e: unknown) =>
14
+ (e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Something went wrong.";
15
+
16
+ /** A monthly-equivalent price, so tiers sold on different cycles still order correctly. */
17
+ const monthlyPrice = (tier: PublicTier) => {
18
+ const cycle = defaultCycle(tier);
19
+ const floor = cycleFloor(tier, cycle);
20
+ return cycle === "year" ? floor / 12 : floor;
21
+ };
22
+
23
+ /**
24
+ * The tiers a member can move DOWN to: this community's tiers priced below the
25
+ * current one, excluding anything the upgrade list offers. Pure.
26
+ */
27
+ export function communityDowngradeTiers(tiers: PublicTier[], currentTierId: string | null | undefined, upgradeTierIds: string[]): PublicTier[] {
28
+ const current = tiers.find((tier) => tier.id === currentTierId);
29
+ if (!current) return [];
30
+ const currentPrice = monthlyPrice(current);
31
+ return tiers.filter(
32
+ (tier) =>
33
+ tier.id !== current.id &&
34
+ !upgradeTierIds.includes(tier.id) &&
35
+ tier.joinMode !== "application" &&
36
+ monthlyPrice(tier) < currentPrice,
37
+ );
38
+ }
39
+
40
+ /**
41
+ * The Membership menu inside a community: current tier, renewal date, a pending
42
+ * downgrade, and the actions. Cancel keeps the paid period; a downgrade takes
43
+ * effect at period end and can be withdrawn until then (decision 11).
44
+ *
45
+ * Cancel is two-step (`requestCancel()` then `confirmCancel()`), because a
46
+ * destructive action has to be confirmed and the confirmation's wording depends
47
+ * on state the hook owns.
48
+ */
49
+ export function useCommunityMembership() {
50
+ const context = useOptionalCommunityContext();
51
+ if (!context) throw new MissingCommunityProviderError("useCommunityMembership()");
52
+ const community = useCommunity();
53
+ const ref = community.slug ?? context.slug ?? context.ref;
54
+ const cancel = useCancelCommunityMembership(ref);
55
+ const downgrade = useDowngradeCommunityMembership(ref);
56
+ const undo = useUndoCommunityDowngrade(ref);
57
+ const billingPortal = useOpenBillingPortal();
58
+
59
+ const [cancelRequested, setCancelRequested] = useState(false);
60
+ const [error, setError] = useState<string | null>(null);
61
+
62
+ const membership = community.membership;
63
+ const upgradeIds = community.upgradeTiers.map((tier) => tier.id);
64
+ const downgradeTiers = useMemo(
65
+ () => communityDowngradeTiers(community.tiers, membership?.tierId, upgradeIds),
66
+ // eslint-disable-next-line react-hooks/exhaustive-deps
67
+ [community.tiers, membership?.tierId, upgradeIds.join(",")],
68
+ );
69
+ const pendingDowngradeTier = membership?.pendingDowngradeTierId
70
+ ? (community.tiers.find((tier) => tier.id === membership.pendingDowngradeTierId) ?? {
71
+ id: membership.pendingDowngradeTierId,
72
+ name: "",
73
+ })
74
+ : null;
75
+
76
+ const run = async (action: () => Promise<unknown>) => {
77
+ setError(null);
78
+ try {
79
+ await action();
80
+ await community.refetch();
81
+ return true;
82
+ } catch (e) {
83
+ setError(errMessage(e));
84
+ return false;
85
+ }
86
+ };
87
+
88
+ return {
89
+ isLoading: community.isLoading,
90
+ /** False for a viewer with nothing to manage here. */
91
+ hasMembership: !!membership,
92
+ membership,
93
+ currentTier: community.currentTier,
94
+ /** When the paid period ends: the renewal date, or the last day when cancelling. */
95
+ periodEnd: membership?.currentPeriodEnd ?? null,
96
+ isCancelling: !!membership?.cancelAtPeriodEnd,
97
+ pendingDowngradeTier,
98
+ downgradeTiers,
99
+ isPastDue: community.viewer?.state === "past_due",
100
+ error,
101
+ isWorking: cancel.isPending || downgrade.isPending || undo.isPending || billingPortal.isPending,
102
+
103
+ cancelRequested,
104
+ /** Ask to cancel: show the confirmation, which must say the paid period is kept. */
105
+ requestCancel: () => {
106
+ setError(null);
107
+ setCancelRequested(true);
108
+ },
109
+ abortCancel: () => setCancelRequested(false),
110
+ /** Cancel for real. Access runs to the end of the paid period. */
111
+ confirmCancel: async () => {
112
+ const ok = await run(() => cancel.mutateAsync());
113
+ if (ok) setCancelRequested(false);
114
+ return ok;
115
+ },
116
+ /** The same, without the confirmation step, for a host that confirms on its own. */
117
+ cancel: () => run(() => cancel.mutateAsync()),
118
+ /** Move down to `tierId` at the end of the paid period. */
119
+ downgrade: (tierId: string) => run(() => downgrade.mutateAsync({ membershipTierId: tierId })),
120
+ /** Withdraw a pending downgrade. */
121
+ undoDowngrade: () => run(() => undo.mutateAsync()),
122
+ /**
123
+ * Open the provider's billing page to change the card. The server derives
124
+ * the customer from the session; the fan comes back to this page.
125
+ */
126
+ updateCard: async () => {
127
+ setError(null);
128
+ try {
129
+ const { url } = await billingPortal.mutateAsync();
130
+ if (typeof window !== "undefined" && url) window.location.assign(url);
131
+ } catch (e) {
132
+ setError(errMessage(e));
133
+ }
134
+ },
135
+ };
136
+ }