@tribe-nest/forge 3.59.0 → 3.61.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 +2 -1
- package/src/_tests/testCommunity.tsx +28 -0
- package/src/contexts/CartContext.tsx +34 -1
- package/src/contexts/CommunityContext.tsx +192 -0
- package/src/contexts/PublicAuthContext.tsx +15 -3
- package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
- package/src/data/queries/useAccount.ts +33 -2
- package/src/data/queries/useAnalytics.ts +6 -6
- package/src/data/queries/useCallChat.ts +2 -2
- package/src/data/queries/useChat.ts +52 -40
- package/src/data/queries/useCheckouts.ts +2 -1
- package/src/data/queries/useCoachingAvailability.ts +10 -1
- package/src/data/queries/useCommunities.ts +345 -0
- package/src/data/queries/useCommunity.ts +450 -102
- package/src/data/queries/useCommunityAgent.ts +247 -0
- package/src/data/queries/useCommunityCalendar.ts +150 -0
- package/src/data/queries/useCommunityClassroom.ts +75 -0
- package/src/data/queries/useCommunityMembers.ts +108 -0
- package/src/data/queries/useCommunityModeration.ts +327 -0
- package/src/data/queries/useCommunityWiki.ts +137 -0
- package/src/data/queries/useCourses.ts +2 -0
- package/src/data/queries/useEmailSignIn.ts +79 -0
- package/src/data/queries/useEvents.ts +6 -1
- package/src/data/queries/useForms.ts +5 -1
- package/src/data/queries/useLeadMagnet.ts +6 -2
- package/src/data/queries/useMembership.ts +32 -7
- package/src/data/queries/useMembershipCalls.ts +116 -7
- package/src/data/queries/useMyBookings.ts +24 -1
- package/src/data/queries/useMyTickets.ts +24 -1
- package/src/data/queries/useNotifications.ts +6 -2
- package/src/data/queries/useOrders.ts +2 -1
- package/src/data/queries/usePWA.ts +111 -35
- package/src/data/queries/usePostCollections.ts +28 -16
- package/src/data/queries/usePosts.ts +16 -14
- package/src/data/queries/useSiteApp.ts +130 -0
- package/src/data/queries/useSubscriptions.ts +28 -9
- package/src/i18n/de.json +891 -2
- package/src/i18n/en.json +891 -2
- package/src/index.ts +36 -7
- package/src/runtime/pages.spec.ts +36 -0
- package/src/runtime/pages.ts +52 -27
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
- package/src/types/models.ts +11 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
- package/src/ui/analytics/PageMetaPixel.tsx +23 -8
- package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
- package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
- package/src/ui/analytics/useMetaEvents.ts +78 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
- package/src/ui/format/membershipGate.ts +19 -15
- package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
- package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
- package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
- package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
- package/src/ui/headless/_tests/communityHarness.tsx +212 -0
- package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
- package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
- package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
- package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
- package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
- package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
- package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
- package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
- package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
- package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
- package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
- package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
- package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
- package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
- package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
- package/src/ui/headless/auth/useSignupForm.ts +30 -3
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
- package/src/ui/headless/chat/useChatRoom.ts +35 -1
- package/src/ui/headless/communities/CommunityGate.tsx +67 -0
- package/src/ui/headless/communities/index.ts +96 -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/useCommunityCalendar.ts +156 -0
- package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
- package/src/ui/headless/communities/useCommunityHome.ts +91 -0
- package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
- package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
- package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
- package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
- package/src/ui/headless/communities/useMyMemberships.ts +59 -0
- package/src/ui/headless/community/communityPoll.ts +297 -0
- package/src/ui/headless/community/countries.ts +123 -0
- package/src/ui/headless/community/index.ts +81 -1
- package/src/ui/headless/community/linkPreview.ts +71 -0
- package/src/ui/headless/community/richLexical.ts +27 -2
- package/src/ui/headless/community/useCommunityAgent.ts +285 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
- package/src/ui/headless/community/useCommunityMembers.ts +175 -0
- package/src/ui/headless/community/useCommunityModeration.ts +300 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
- package/src/ui/headless/community/useCommunityThread.ts +16 -3
- package/src/ui/headless/community/useMentionComposer.ts +17 -9
- package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
- package/src/ui/headless/downloads/index.ts +8 -0
- package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
- package/src/ui/headless/index.ts +21 -4
- package/src/ui/headless/memberHome/index.ts +11 -1
- package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
- 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/portal/index.ts +57 -0
- package/src/ui/headless/portal/usePortal.ts +274 -0
- package/src/ui/headless/portal/usePortalFiles.ts +68 -0
- package/src/ui/headless/portal/usePortalMessages.ts +189 -0
- package/src/ui/headless/portal/usePortalPreview.ts +142 -0
- 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/headless/siteApp/appBar.ts +102 -0
- package/src/ui/headless/siteApp/appHome.ts +344 -0
- package/src/ui/headless/siteApp/appPrompts.ts +229 -0
- package/src/ui/headless/siteApp/index.ts +58 -0
- package/src/ui/headless/siteApp/useAppHome.ts +210 -0
- package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
- package/src/ui/headless/work/index.ts +2 -0
- package/src/ui/headless/work/useWorkPortal.ts +37 -26
- package/src/ui/index.ts +28 -12
- package/src/ui/styled/AccountDashboard.tsx +754 -602
- package/src/ui/styled/AiAgentWidget.tsx +13 -1
- package/src/ui/styled/ChatRoom.tsx +126 -16
- package/src/ui/styled/Checkout.tsx +8 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingDetail.tsx +2 -0
- package/src/ui/styled/CourseConfirmation.tsx +5 -0
- package/src/ui/styled/CourseDetail.tsx +2 -0
- package/src/ui/styled/DownloadPage.tsx +263 -0
- package/src/ui/styled/EventConfirmation.tsx +10 -0
- package/src/ui/styled/EventDetail.tsx +10 -0
- package/src/ui/styled/FilmLibrary.tsx +22 -9
- 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/MusicLinkPage.tsx +5 -5
- package/src/ui/styled/Paywall.tsx +1 -1
- package/src/ui/styled/ProductDetail.tsx +12 -0
- package/src/ui/styled/PwaRegistration.tsx +5 -1
- package/src/ui/styled/SignupForm.tsx +18 -3
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
- 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/AccountNotifications.spec.tsx +135 -0
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
- package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
- 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/app/AppBar.tsx +298 -0
- package/src/ui/styled/app/AppHome.tsx +1139 -0
- package/src/ui/styled/app/AppHomeMark.tsx +66 -0
- package/src/ui/styled/app/AppPrompts.tsx +464 -0
- package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
- package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
- package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
- package/src/ui/styled/app/index.ts +7 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
- package/src/ui/styled/chat/MessageBubble.tsx +43 -6
- package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
- package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
- package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
- package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
- package/src/ui/styled/communities/CommunityHome.tsx +298 -0
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
- package/src/ui/styled/communities/CommunityPending.tsx +49 -0
- package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
- package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
- package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
- package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
- package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
- package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
- package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
- package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
- package/src/ui/styled/communities/index.ts +19 -0
- package/src/ui/styled/communities/shared.tsx +177 -0
- package/src/ui/styled/community/CommunityAgent.tsx +286 -0
- package/src/ui/styled/community/CommunityComposer.tsx +266 -15
- package/src/ui/styled/community/CommunityFeed.tsx +50 -1
- package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
- package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
- package/src/ui/styled/community/CommunityMembers.tsx +387 -0
- package/src/ui/styled/community/CommunityModeration.tsx +587 -0
- package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
- package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
- package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
- package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
- package/src/ui/styled/community/CommunityRichText.tsx +177 -32
- package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
- package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
- package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
- package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
- package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
- package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
- package/src/ui/styled/community/index.ts +37 -2
- package/src/ui/styled/forge-utilities.css +253 -0
- package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
- package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
- package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
- package/src/ui/styled/members/MemberPostCard.tsx +9 -5
- package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
- 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/ui/styled/members/index.ts +8 -0
- package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
- package/src/ui/styled/portal/PortalActivity.tsx +167 -0
- package/src/ui/styled/portal/PortalBilling.tsx +197 -0
- package/src/ui/styled/portal/PortalFiles.tsx +279 -0
- package/src/ui/styled/portal/PortalHome.tsx +401 -0
- package/src/ui/styled/portal/PortalMessages.tsx +300 -0
- package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
- package/src/ui/styled/portal/PortalProjects.tsx +407 -0
- package/src/ui/styled/portal/PortalShell.tsx +397 -0
- package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
- package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
- package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
- package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
- package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
- package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
- package/src/ui/styled/portal/index.ts +13 -0
- package/src/ui/styled/portal/portalNav.ts +260 -0
- package/src/ui/styled/portal/shared.tsx +206 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
- package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
- package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
- package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
- package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
- package/src/ui/styled/work/WorkReportBody.tsx +223 -248
- package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
- package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
- package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
- package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
- package/src/ui/styled/work/index.ts +1 -1
- package/src/utils/_tests/metaEvents.spec.ts +264 -0
- package/src/utils/_tests/safeRedirect.spec.ts +2 -2
- package/src/utils/landing.ts +19 -6
- package/src/utils/metaEvents.ts +302 -0
- 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,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
|
+
});
|
|
@@ -0,0 +1,494 @@
|
|
|
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 { useEmailCodeSignIn } from "../auth/useEmailCodeSignIn";
|
|
9
|
+
import { offeredCycles } from "../../format/membershipPwyw";
|
|
10
|
+
import { useCommunity } from "./useCommunity";
|
|
11
|
+
import { useCommunityInvite } from "./useCommunityInvite";
|
|
12
|
+
import { communityHomePath } from "./paths";
|
|
13
|
+
import { requestAppInstall } from "../siteApp/appPrompts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Where the join sheet is. Steps that do not apply are skipped, never shown:
|
|
17
|
+
*
|
|
18
|
+
* email who the fan is: their email address, nothing else. Skipped
|
|
19
|
+
* when signed in. (`switchToPassword()` swaps in the email and
|
|
20
|
+
* password sign-in for someone who prefers it.)
|
|
21
|
+
* code the 6-digit code emailed to them. Skipped when signed in.
|
|
22
|
+
* Right for an address with an account here: signed in.
|
|
23
|
+
* profile right, but nobody has that address here yet: first name, last
|
|
24
|
+
* name and the terms. Only for someone new.
|
|
25
|
+
* tier which tier, and on a tier with a choice, the billing cycle or
|
|
26
|
+
* the pay-what-you-want amount. Skipped when there is one tier
|
|
27
|
+
* and nothing to choose.
|
|
28
|
+
* questions the tier's questions. Only when it has some.
|
|
29
|
+
* payment the card. Only on a paid tier (or a paid application, which
|
|
30
|
+
* saves the card and charges on approval).
|
|
31
|
+
* submitting a join request is in flight.
|
|
32
|
+
* joined in. The sheet lands the fan on the community home.
|
|
33
|
+
* pending an application was lodged: the fan lands inside, pending.
|
|
34
|
+
*/
|
|
35
|
+
export type CommunityJoinStep = "email" | "code" | "profile" | "tier" | "questions" | "payment" | "submitting" | "joined" | "pending";
|
|
36
|
+
|
|
37
|
+
/** The facts the step is derived from. Exported so the order can be tested without a provider tree. */
|
|
38
|
+
export interface CommunityJoinStepInput {
|
|
39
|
+
isAuthenticated: boolean;
|
|
40
|
+
awaitingCode: boolean;
|
|
41
|
+
/** The code was right and the address is new here: the name step. */
|
|
42
|
+
needsProfile?: boolean;
|
|
43
|
+
/** A per-email invite for another address, while signed in: the sheet offers "Sign in as". */
|
|
44
|
+
emailMismatch: boolean;
|
|
45
|
+
joined: boolean;
|
|
46
|
+
pending: boolean;
|
|
47
|
+
/** Already a member here and not changing tier through this sheet. */
|
|
48
|
+
alreadyMember: boolean;
|
|
49
|
+
isProcessing: boolean;
|
|
50
|
+
hasPaymentStep: boolean;
|
|
51
|
+
hasSelectedTier: boolean;
|
|
52
|
+
/** The tier step still needs the fan (several tiers, or a choice on the one). */
|
|
53
|
+
tierStepOpen: boolean;
|
|
54
|
+
/** The tier's questions are waiting for answers. */
|
|
55
|
+
questionsOpen: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The join step, pure. The ORDER here is the flow: identity, then tier, questions, card. */
|
|
59
|
+
export function communityJoinStep(input: CommunityJoinStepInput): CommunityJoinStep {
|
|
60
|
+
if (input.joined || (input.alreadyMember && input.isAuthenticated)) return "joined";
|
|
61
|
+
if (input.pending) return "pending";
|
|
62
|
+
if (!input.isAuthenticated) return input.needsProfile ? "profile" : input.awaitingCode ? "code" : "email";
|
|
63
|
+
if (input.emailMismatch) return "email";
|
|
64
|
+
if (input.isProcessing) return "submitting";
|
|
65
|
+
if (input.hasPaymentStep) return "payment";
|
|
66
|
+
if (!input.hasSelectedTier || input.tierStepOpen) return "tier";
|
|
67
|
+
if (input.questionsOpen) return "questions";
|
|
68
|
+
return "submitting";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Does this tier ask the fan to choose anything (a cycle, or an amount)? */
|
|
72
|
+
export function tierHasChoice(tier: Pick<PublicTier, "payWhatYouWant" | "priceMonthly" | "priceYearly" | "payWhatYouWantMinimum" | "payWhatYouWantYearlyMinimum" | "payWhatYouWantMaximum" | "payWhatYouWantYearlyMaximum">): boolean {
|
|
73
|
+
if (tier.payWhatYouWant) return true;
|
|
74
|
+
const cycles = offeredCycles(tier);
|
|
75
|
+
return cycles.month && cycles.year;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface UseCommunityJoinOptions {
|
|
79
|
+
/** Preselect a tier (`?tier=` on the About page). An invite's tier wins over it. */
|
|
80
|
+
tierId?: string | null;
|
|
81
|
+
/** A per-email invite token (`?invite=`): its tier is preset and its email locked. */
|
|
82
|
+
invite?: string | null;
|
|
83
|
+
/** A shareable invite link token (`?join=`): its tier is preset, no email lock. */
|
|
84
|
+
join?: string | null;
|
|
85
|
+
/**
|
|
86
|
+
* Where Stripe returns after a paid join. Default: the community home with
|
|
87
|
+
* `confirmSubscription=1`, which reconciles the subscription there.
|
|
88
|
+
*/
|
|
89
|
+
returnPath?: string;
|
|
90
|
+
/**
|
|
91
|
+
* Called when the join is complete or an application is lodged. Omitted, the
|
|
92
|
+
* fan is sent to the community home (with `welcome=1` after a join).
|
|
93
|
+
*/
|
|
94
|
+
onJoined?: (result: { pending: boolean; requiresConfirmation: boolean; path: string }) => void;
|
|
95
|
+
/** The host's router, used for the default landing instead of a full-page visit. */
|
|
96
|
+
navigate?: (href: string) => void;
|
|
97
|
+
/** Require the terms checkbox on sign-up (default true). */
|
|
98
|
+
requireAcceptance?: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Join a community from its About page, in place: Skool-simple.
|
|
103
|
+
*
|
|
104
|
+
* email -> code -> (profile) -> tier -> questions -> payment -> inside
|
|
105
|
+
*
|
|
106
|
+
* with every step that does not apply skipped. Identity is email first: the
|
|
107
|
+
* code decides whether the fan already has an account (signed in) or is new
|
|
108
|
+
* (asked their name), so nobody picks between "Create account" and "Sign in". A free tier with nothing to
|
|
109
|
+
* choose is joined the moment the code is accepted. Every success lands INSIDE
|
|
110
|
+
* the community (`/i/communities/<slug>/home`), never on the account page.
|
|
111
|
+
*
|
|
112
|
+
* Composes, rather than re-implements: the email-first sign-in
|
|
113
|
+
* (`useEmailCodeSignIn`), the password sign-in (`useLoginFlow`) and the
|
|
114
|
+
* password sign-up (`useSignupForm`, kept for a custom sheet that sets
|
|
115
|
+
* `mode` to `signup`), the membership checkout (`useMembershipCheckout`:
|
|
116
|
+
* pricing, pay-what-you-want, questions, trials, free and paid subscribe,
|
|
117
|
+
* applications, Stripe and Paystack), the community read (`useCommunity`) and
|
|
118
|
+
* the invite read (`useCommunityInvite`).
|
|
119
|
+
*
|
|
120
|
+
* Invites:
|
|
121
|
+
* - a per-email invite presets its tier and prefills and LOCKS the email. A fan
|
|
122
|
+
* signed in as someone else gets `emailMismatch` and `signInAsInvited()`.
|
|
123
|
+
* - a shareable link presets its tier with no email lock.
|
|
124
|
+
* - a dead token (expired, revoked, used, used up, not found) is reported in
|
|
125
|
+
* `inviteProblem` and NOT forwarded, so the normal Join still works.
|
|
126
|
+
*
|
|
127
|
+
* The card step exposes what `ForgePaymentProvider`'s renderer needs:
|
|
128
|
+
* `clientSecret`, `paymentKind`, `returnUrl`, `amount`, `trial`, and
|
|
129
|
+
* `onPaymentSucceeded`; on Paystack, `openPaystackCheckout`.
|
|
130
|
+
*/
|
|
131
|
+
export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
|
|
132
|
+
const context = useOptionalCommunityContext();
|
|
133
|
+
if (!context) throw new MissingCommunityProviderError("useCommunityJoin()");
|
|
134
|
+
const auth = useContext(PublicAuthContext);
|
|
135
|
+
const user = auth?.user ?? null;
|
|
136
|
+
const isAuthenticated = !!user;
|
|
137
|
+
|
|
138
|
+
const inviteToken = opts.invite ?? null;
|
|
139
|
+
const joinToken = inviteToken ? null : (opts.join ?? null);
|
|
140
|
+
const invite = useCommunityInvite({ invite: inviteToken, join: joinToken });
|
|
141
|
+
// Only a usable token changes what is listed and what is sent.
|
|
142
|
+
const forwardInvite = invite.forward.inviteToken ?? null;
|
|
143
|
+
const forwardJoin = invite.forward.joinToken ?? null;
|
|
144
|
+
const community = useCommunity(undefined, { invite: forwardInvite, join: forwardJoin });
|
|
145
|
+
const invalidate = useInvalidateCommunities();
|
|
146
|
+
|
|
147
|
+
const slug = community.slug ?? context.slug;
|
|
148
|
+
const home = (query?: { welcome?: 1; confirmSubscription?: 1 }) => (slug ? communityHomePath(slug, query) : "");
|
|
149
|
+
const paidReturnPath = opts.returnPath ?? home({ confirmSubscription: 1 });
|
|
150
|
+
|
|
151
|
+
// ---- Tiers -------------------------------------------------------------------
|
|
152
|
+
const tiers = useMemo(() => {
|
|
153
|
+
const list = [...community.tiers];
|
|
154
|
+
// The invite's tier is joinable even when the public list hides it.
|
|
155
|
+
if (invite.tier && !list.some((tier) => tier.id === invite.tier!.id)) list.unshift(invite.tier);
|
|
156
|
+
return list;
|
|
157
|
+
}, [community.tiers, invite.tier]);
|
|
158
|
+
const presetTierId =
|
|
159
|
+
invite.tier?.id ?? (opts.tierId && tiers.some((tier) => tier.id === opts.tierId) ? opts.tierId : null) ??
|
|
160
|
+
(tiers.length === 1 ? tiers[0]!.id : null);
|
|
161
|
+
|
|
162
|
+
// ---- Landing -------------------------------------------------------------------
|
|
163
|
+
const [joined, setJoined] = useState(false);
|
|
164
|
+
const [landedPending, setLandedPending] = useState(false);
|
|
165
|
+
const land = useCallback(
|
|
166
|
+
(result: { pending: boolean; requiresConfirmation: boolean }) => {
|
|
167
|
+
void invalidate();
|
|
168
|
+
// In, with nothing left to pay: the moment the site app offers itself.
|
|
169
|
+
if (!result.pending && !result.requiresConfirmation) requestAppInstall("joined_community");
|
|
170
|
+
const path = result.pending ? home() : result.requiresConfirmation ? paidReturnPath : home({ welcome: 1 });
|
|
171
|
+
if (opts.onJoined) return opts.onJoined({ ...result, path });
|
|
172
|
+
if (opts.navigate) return opts.navigate(path);
|
|
173
|
+
if (typeof window !== "undefined" && path) window.location.assign(`${window.location.origin}${path}`);
|
|
174
|
+
},
|
|
175
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
176
|
+
[slug, paidReturnPath, opts.onJoined, opts.navigate],
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const checkout = useMembershipCheckout({
|
|
180
|
+
tiers,
|
|
181
|
+
initialTierId: presetTierId ?? undefined,
|
|
182
|
+
inviteToken: forwardInvite,
|
|
183
|
+
joinToken: forwardJoin,
|
|
184
|
+
current: community.membership ? { tierId: community.membership.tierId, status: community.membership.status } : null,
|
|
185
|
+
returnPath: paidReturnPath,
|
|
186
|
+
freeReturnPath: home({ welcome: 1 }),
|
|
187
|
+
applicationReturnPath: home(),
|
|
188
|
+
onComplete: ({ requiresConfirmation }) => {
|
|
189
|
+
setJoined(true);
|
|
190
|
+
land({ pending: false, requiresConfirmation });
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// The tier list can arrive after the first render (and an invite's tier after
|
|
195
|
+
// that): select the preset once it exists.
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (!checkout.selectedTierId && presetTierId && tiers.some((tier) => tier.id === presetTierId)) {
|
|
198
|
+
checkout.setSelectedTierId(presetTierId);
|
|
199
|
+
checkout.setStep("select");
|
|
200
|
+
}
|
|
201
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
202
|
+
}, [presetTierId, tiers]);
|
|
203
|
+
|
|
204
|
+
// ---- Identity (email + code) -------------------------------------------------------
|
|
205
|
+
/**
|
|
206
|
+
* `email` (the default): email first, the code decides. `signin`: the email
|
|
207
|
+
* and password sign-in, for someone who prefers their password. `signup`: the
|
|
208
|
+
* password sign-up, which the styled sheet no longer offers.
|
|
209
|
+
*/
|
|
210
|
+
const [mode, setMode] = useState<"email" | "signup" | "signin">("email");
|
|
211
|
+
const emailFlow = useEmailCodeSignIn({ requireAcceptance: opts.requireAcceptance });
|
|
212
|
+
const signup = useSignupForm({
|
|
213
|
+
membershipTierId: checkout.selectedTierId,
|
|
214
|
+
requireAcceptance: opts.requireAcceptance,
|
|
215
|
+
});
|
|
216
|
+
const login = useLoginFlow();
|
|
217
|
+
const lockedEmail = invite.kind === "email" && (invite.isValid || invite.emailMismatch) ? invite.invitedEmail : null;
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
if (!lockedEmail) return;
|
|
220
|
+
if (signup.email !== lockedEmail) signup.setEmail(lockedEmail);
|
|
221
|
+
if (login.email !== lockedEmail) login.setEmail(lockedEmail);
|
|
222
|
+
if (emailFlow.email !== lockedEmail) emailFlow.setEmail(lockedEmail);
|
|
223
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
224
|
+
}, [lockedEmail, signup.email, login.email, emailFlow.email]);
|
|
225
|
+
const awaitingCode =
|
|
226
|
+
mode === "email" ? emailFlow.step === "code" : mode === "signup" ? signup.awaitingCode : login.step === "code";
|
|
227
|
+
const needsProfile = mode === "email" && emailFlow.step === "profile";
|
|
228
|
+
|
|
229
|
+
// ---- Tier and question confirmation ------------------------------------------------
|
|
230
|
+
const [tierConfirmed, setTierConfirmed] = useState(false);
|
|
231
|
+
const [questionsConfirmed, setQuestionsConfirmed] = useState(false);
|
|
232
|
+
const [attempted, setAttempted] = useState(false);
|
|
233
|
+
const selectedTier = checkout.selectedTier as PublicTier | undefined;
|
|
234
|
+
// An invite names its tier, so the list is not offered; a choice ON the tier still is.
|
|
235
|
+
const offersTierList = tiers.length > 1 && !invite.tier;
|
|
236
|
+
const tierStepNeeded = offersTierList || (!!selectedTier && tierHasChoice(selectedTier));
|
|
237
|
+
// A join that was refused (the amount below the floor, a server error) comes
|
|
238
|
+
// back to the tier step, which owns the message and the retry.
|
|
239
|
+
const refused = attempted && !checkout.isProcessing && (!!checkout.error || !!checkout.amountRefusal);
|
|
240
|
+
const tierStepOpen = !selectedTier || (tierStepNeeded && !tierConfirmed) || refused;
|
|
241
|
+
const questionsOpen = checkout.questionnaire.length > 0 && !questionsConfirmed;
|
|
242
|
+
|
|
243
|
+
const isChangingTier = !!community.membership && !!selectedTier && selectedTier.id !== community.membership.tierId && (!!invite.tier || !!opts.tierId);
|
|
244
|
+
const alreadyMember = community.isMember && !isChangingTier;
|
|
245
|
+
const pending =
|
|
246
|
+
landedPending ||
|
|
247
|
+
checkout.applicationOutcome === "received" ||
|
|
248
|
+
checkout.applicationOutcome === "approved" ||
|
|
249
|
+
(community.isPending && !joined);
|
|
250
|
+
|
|
251
|
+
const hasPaymentStep = checkout.step === "payment" && (!!checkout.clientSecret || checkout.canOpenPaystack);
|
|
252
|
+
|
|
253
|
+
const step = communityJoinStep({
|
|
254
|
+
isAuthenticated,
|
|
255
|
+
awaitingCode,
|
|
256
|
+
needsProfile,
|
|
257
|
+
emailMismatch: isAuthenticated && invite.emailMismatch,
|
|
258
|
+
joined,
|
|
259
|
+
pending,
|
|
260
|
+
alreadyMember,
|
|
261
|
+
isProcessing: checkout.isProcessing,
|
|
262
|
+
hasPaymentStep,
|
|
263
|
+
hasSelectedTier: !!selectedTier,
|
|
264
|
+
tierStepOpen,
|
|
265
|
+
questionsOpen,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// An application lodged: land inside, pending (decision 9).
|
|
269
|
+
useEffect(() => {
|
|
270
|
+
if (landedPending) return;
|
|
271
|
+
if (checkout.applicationOutcome === "received" || checkout.applicationOutcome === "approved") {
|
|
272
|
+
setLandedPending(true);
|
|
273
|
+
land({ pending: true, requiresConfirmation: false });
|
|
274
|
+
}
|
|
275
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
276
|
+
}, [checkout.applicationOutcome]);
|
|
277
|
+
|
|
278
|
+
const submit = useCallback(async () => {
|
|
279
|
+
setAttempted(true);
|
|
280
|
+
if (selectedTier) autoSubmitted.current = selectedTier.id;
|
|
281
|
+
await checkout.subscribe();
|
|
282
|
+
// A refusal (an amount below the floor, a blank required answer, a server
|
|
283
|
+
// error) leaves nothing started: reopen the step that owns the message.
|
|
284
|
+
if (checkout.questionnaire.length > 0) setQuestionsConfirmed(false);
|
|
285
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
286
|
+
}, [checkout.subscribe, checkout.questionnaire.length]);
|
|
287
|
+
|
|
288
|
+
// Nothing left to ask: join straight away. Once per tier, so a refusal does
|
|
289
|
+
// not loop; the fan retries from the tier step, which then owns the error.
|
|
290
|
+
const autoSubmitted = useRef<string | null>(null);
|
|
291
|
+
const viewerSettled = !community.viewerLoading && !community.isLoading;
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
if (step !== "submitting" || checkout.isProcessing || !selectedTier || !viewerSettled) return;
|
|
294
|
+
if (autoSubmitted.current === selectedTier.id) return;
|
|
295
|
+
autoSubmitted.current = selectedTier.id;
|
|
296
|
+
void submit();
|
|
297
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
298
|
+
}, [step, checkout.isProcessing, selectedTier?.id, viewerSettled]);
|
|
299
|
+
|
|
300
|
+
// The already-member case lands straight inside, once.
|
|
301
|
+
const landedMember = useRef(false);
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
if (step === "joined" && alreadyMember && !joined && !landedMember.current) {
|
|
304
|
+
landedMember.current = true;
|
|
305
|
+
if (slug) {
|
|
306
|
+
const path = home();
|
|
307
|
+
if (opts.onJoined) opts.onJoined({ pending: false, requiresConfirmation: false, path });
|
|
308
|
+
else if (opts.navigate) opts.navigate(path);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
312
|
+
}, [step, alreadyMember, joined, slug]);
|
|
313
|
+
|
|
314
|
+
const identityError = mode === "email" ? emailFlow.error : mode === "signup" ? signup.error : login.error;
|
|
315
|
+
const identitySubmitting =
|
|
316
|
+
mode === "email" ? emailFlow.isSubmitting : mode === "signup" ? signup.isSubmitting : login.isSubmitting;
|
|
317
|
+
|
|
318
|
+
/** Advance from the current step. */
|
|
319
|
+
const next = async () => {
|
|
320
|
+
switch (step) {
|
|
321
|
+
case "email":
|
|
322
|
+
if (mode === "email") await emailFlow.submitEmail();
|
|
323
|
+
else if (mode === "signup") await signup.submit();
|
|
324
|
+
else await login.submitCredentials();
|
|
325
|
+
return;
|
|
326
|
+
case "code":
|
|
327
|
+
if (mode === "email") await emailFlow.submitCode();
|
|
328
|
+
else if (mode === "signup") await signup.submitCode();
|
|
329
|
+
else await login.submitCode();
|
|
330
|
+
return;
|
|
331
|
+
case "profile":
|
|
332
|
+
await emailFlow.submitProfile();
|
|
333
|
+
return;
|
|
334
|
+
case "tier":
|
|
335
|
+
if (!selectedTier) return;
|
|
336
|
+
setTierConfirmed(true);
|
|
337
|
+
// The auto-submit is spent on a retry: pressing the button is the retry.
|
|
338
|
+
autoSubmitted.current = selectedTier.id;
|
|
339
|
+
if (checkout.questionnaire.length > 0 && !questionsConfirmed) return;
|
|
340
|
+
await submit();
|
|
341
|
+
return;
|
|
342
|
+
case "questions":
|
|
343
|
+
if (selectedTier) autoSubmitted.current = selectedTier.id;
|
|
344
|
+
setQuestionsConfirmed(true);
|
|
345
|
+
await submit();
|
|
346
|
+
return;
|
|
347
|
+
default:
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
/** Go back one step, where there is one to go back to. */
|
|
353
|
+
const back = () => {
|
|
354
|
+
switch (step) {
|
|
355
|
+
case "code":
|
|
356
|
+
case "profile":
|
|
357
|
+
if (mode === "email") emailFlow.changeEmail();
|
|
358
|
+
else if (mode === "signup") signup.editDetails();
|
|
359
|
+
else login.back();
|
|
360
|
+
return;
|
|
361
|
+
case "questions":
|
|
362
|
+
if (tierStepNeeded) setTierConfirmed(false);
|
|
363
|
+
return;
|
|
364
|
+
case "payment":
|
|
365
|
+
checkout.setStep("select");
|
|
366
|
+
if (checkout.questionnaire.length > 0) setQuestionsConfirmed(false);
|
|
367
|
+
else if (tierStepNeeded) setTierConfirmed(false);
|
|
368
|
+
autoSubmitted.current = selectedTier?.id ?? null;
|
|
369
|
+
return;
|
|
370
|
+
default:
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
step,
|
|
377
|
+
isLoading: community.isLoading || invite.isLoading,
|
|
378
|
+
community: community.community,
|
|
379
|
+
slug,
|
|
380
|
+
tiers,
|
|
381
|
+
viewer: community.viewer,
|
|
382
|
+
/** Already a member here: the sheet has nothing to do but go inside. */
|
|
383
|
+
alreadyMember,
|
|
384
|
+
|
|
385
|
+
// ---- email + code ----
|
|
386
|
+
/** `email` (email first, the default), `signin` (email and password) or `signup` (password sign-up). */
|
|
387
|
+
mode,
|
|
388
|
+
setMode,
|
|
389
|
+
/** The email-first fields and steps (email, code, then a name for someone new). */
|
|
390
|
+
emailFlow,
|
|
391
|
+
/** "Use your password instead": the email and password sign-in, carrying the typed address over. */
|
|
392
|
+
switchToPassword: () => {
|
|
393
|
+
if (!lockedEmail && emailFlow.email && !login.email) login.setEmail(emailFlow.email);
|
|
394
|
+
setMode("signin");
|
|
395
|
+
},
|
|
396
|
+
/** Back from the password sign-in to email first. */
|
|
397
|
+
switchToEmailCode: () => {
|
|
398
|
+
if (!lockedEmail && login.email && !emailFlow.email) emailFlow.setEmail(login.email);
|
|
399
|
+
login.back();
|
|
400
|
+
setMode("email");
|
|
401
|
+
},
|
|
402
|
+
/** The sign-up fields (name, email, password, terms). */
|
|
403
|
+
signup,
|
|
404
|
+
/** The sign-in fields (email, password, then code). */
|
|
405
|
+
login,
|
|
406
|
+
/** The email is set by a per-email invite and cannot be changed. */
|
|
407
|
+
emailLocked: !!lockedEmail,
|
|
408
|
+
lockedEmail,
|
|
409
|
+
/** The code the fan types on the `code` step, whichever mode. */
|
|
410
|
+
code: mode === "email" ? emailFlow.code : mode === "signup" ? signup.code : login.code,
|
|
411
|
+
setCode: (value: string) =>
|
|
412
|
+
mode === "email" ? emailFlow.setCode(value) : mode === "signup" ? signup.setCode(value) : login.setCode(value),
|
|
413
|
+
resendCode: () =>
|
|
414
|
+
mode === "email" ? emailFlow.resendCode() : mode === "signup" ? signup.resendCode() : login.resend(),
|
|
415
|
+
/** The address the code went to, whichever mode. */
|
|
416
|
+
codeEmail: mode === "email" ? emailFlow.email : mode === "signup" ? signup.email : login.email,
|
|
417
|
+
|
|
418
|
+
// ---- invites ----
|
|
419
|
+
invite,
|
|
420
|
+
/** Why the URL's invite cannot be used, if it cannot. The normal Join still works. */
|
|
421
|
+
inviteProblem: invite.problem,
|
|
422
|
+
/** Signed in as another address than a per-email invite's. */
|
|
423
|
+
emailMismatch: isAuthenticated && invite.emailMismatch ? { invitedEmail: invite.invitedEmail ?? "", signedInEmail: user?.email ?? "" } : null,
|
|
424
|
+
/** "Sign in as {email}": signs the current account out and starts email first with the invited address locked. */
|
|
425
|
+
signInAsInvited: async () => {
|
|
426
|
+
await auth?.logout();
|
|
427
|
+
emailFlow.changeEmail();
|
|
428
|
+
setMode("email");
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
// ---- tier ----
|
|
432
|
+
selectedTier,
|
|
433
|
+
selectTier: (tier: PublicTier) => {
|
|
434
|
+
checkout.selectTier(tier);
|
|
435
|
+
setTierConfirmed(false);
|
|
436
|
+
setQuestionsConfirmed(false);
|
|
437
|
+
autoSubmitted.current = null;
|
|
438
|
+
},
|
|
439
|
+
/** True when more than one tier is offered, so the step lists them. False on an invite, which names its tier. */
|
|
440
|
+
hasTierChoice: offersTierList,
|
|
441
|
+
billingCycle: checkout.billingCycle,
|
|
442
|
+
setBillingCycle: checkout.setBillingCycle,
|
|
443
|
+
cycles: checkout.cycles,
|
|
444
|
+
isFreeCycle: checkout.isFreeCycle,
|
|
445
|
+
customAmount: checkout.customAmount,
|
|
446
|
+
setCustomAmount: checkout.setCustomAmount,
|
|
447
|
+
minimumAmount: checkout.minimumAmount,
|
|
448
|
+
maximumAmount: checkout.maximumAmount,
|
|
449
|
+
amountRefusal: checkout.amountRefusal,
|
|
450
|
+
amount: checkout.amount,
|
|
451
|
+
trialDays: checkout.trialDays,
|
|
452
|
+
trialEndsAt: checkout.trialEndsAt,
|
|
453
|
+
isApplication: checkout.isApplication,
|
|
454
|
+
isChange: checkout.isChange,
|
|
455
|
+
|
|
456
|
+
// ---- questions ----
|
|
457
|
+
questionnaire: checkout.questionnaire,
|
|
458
|
+
questionnaireAnswers: checkout.questionnaireAnswers,
|
|
459
|
+
setQuestionnaireAnswer: checkout.setQuestionnaireAnswer,
|
|
460
|
+
questionnaireRefusal: checkout.questionnaireRefusal,
|
|
461
|
+
|
|
462
|
+
// ---- payment ----
|
|
463
|
+
clientSecret: checkout.clientSecret,
|
|
464
|
+
/** `setup` when the card is saved rather than charged (a trial, or an application). */
|
|
465
|
+
paymentKind: checkout.paymentKind,
|
|
466
|
+
setupPurpose: checkout.setupPurpose,
|
|
467
|
+
/** The renderer's `returnUrl`: the community home, reconciling the subscription there. */
|
|
468
|
+
returnUrl:
|
|
469
|
+
checkout.setupPurpose === "application" && checkout.applicationReturnUrl
|
|
470
|
+
? checkout.applicationReturnUrl
|
|
471
|
+
: `${typeof window !== "undefined" ? window.location.origin : ""}${paidReturnPath}`,
|
|
472
|
+
trial:
|
|
473
|
+
checkout.setupPurpose === "trial" && checkout.trialEndsAt
|
|
474
|
+
? { endsAt: checkout.trialEndsAt, billingCycle: checkout.billingCycle }
|
|
475
|
+
: null,
|
|
476
|
+
isPaystack: checkout.isPaystack,
|
|
477
|
+
canOpenPaystack: checkout.canOpenPaystack,
|
|
478
|
+
openPaystackCheckout: checkout.openPaystackCheckout,
|
|
479
|
+
onPaymentSucceeded: checkout.onPaymentSucceeded,
|
|
480
|
+
|
|
481
|
+
// ---- outcome ----
|
|
482
|
+
application: checkout.application,
|
|
483
|
+
applicationOutcome: checkout.applicationOutcome,
|
|
484
|
+
/** Whether a join was attempted in this sheet. */
|
|
485
|
+
attempted,
|
|
486
|
+
error: step === "email" || step === "code" || step === "profile" ? identityError : checkout.error,
|
|
487
|
+
isSubmitting: identitySubmitting || checkout.isProcessing,
|
|
488
|
+
|
|
489
|
+
next,
|
|
490
|
+
back,
|
|
491
|
+
/** Submit the join now (the tier and questions as they stand). */
|
|
492
|
+
submit,
|
|
493
|
+
};
|
|
494
|
+
}
|