@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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
2
|
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
3
|
+
import { useOptionalCommunityContext } from "../../../contexts/CommunityContext";
|
|
4
|
+
import { communityHomePath, communitiesListPath } from "../communities/paths";
|
|
3
5
|
import {
|
|
4
6
|
readMembershipInviteFromUrl,
|
|
5
7
|
useApplyForMembership,
|
|
@@ -55,9 +57,13 @@ export type { BillingCycle };
|
|
|
55
57
|
export interface UseMembershipCheckoutOptions {
|
|
56
58
|
/** Pre-select a tier (e.g. from a `?membershipTierId=` param). */
|
|
57
59
|
initialTierId?: string;
|
|
58
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Where the provider returns the member after a paid subscription. Default:
|
|
62
|
+
* the community home with `confirmSubscription=1` (the community comes from
|
|
63
|
+
* `<CommunityProvider>`), or `/i/communities` outside one. Never the account page.
|
|
64
|
+
*/
|
|
59
65
|
returnPath?: string;
|
|
60
|
-
/** Where free activations land. Default
|
|
66
|
+
/** Where free activations land. Default: the community home with `welcome=1`. */
|
|
61
67
|
freeReturnPath?: string;
|
|
62
68
|
/**
|
|
63
69
|
* Called when the subscription completes IN-APP with nothing left to pay: a
|
|
@@ -73,13 +79,30 @@ export interface UseMembershipCheckoutOptions {
|
|
|
73
79
|
* the current url is used; `null` sends none.
|
|
74
80
|
*/
|
|
75
81
|
inviteToken?: string | null;
|
|
82
|
+
/**
|
|
83
|
+
* A shareable invite link token (`?join=`), sent with every join call. It
|
|
84
|
+
* opens an invite-only tier; a paid tier still charges.
|
|
85
|
+
*/
|
|
86
|
+
joinToken?: string | null;
|
|
76
87
|
/**
|
|
77
88
|
* Where Stripe returns after a redirect-based card confirmation on an
|
|
78
|
-
* APPLICATION (a card that needed a bank redirect). Default
|
|
79
|
-
*
|
|
80
|
-
*
|
|
89
|
+
* APPLICATION (a card that needed a bank redirect). Default: the community
|
|
90
|
+
* home, where a pending application is shown. The application id is appended
|
|
91
|
+
* as `confirmApplication`.
|
|
81
92
|
*/
|
|
82
93
|
applicationReturnPath?: string;
|
|
94
|
+
/**
|
|
95
|
+
* The tiers to offer, when the caller already has them (the community read
|
|
96
|
+
* returns exactly the tiers this viewer may join). Omitted, they are fetched.
|
|
97
|
+
*/
|
|
98
|
+
tiers?: MembershipTier[];
|
|
99
|
+
/**
|
|
100
|
+
* The viewer's current membership IN THIS COMMUNITY, when the caller knows it.
|
|
101
|
+
* Decides whether subscribing is a tier change. Omitted, the signed-in
|
|
102
|
+
* account's single legacy `membership` is read, which knows nothing of
|
|
103
|
+
* communities, so a community surface always passes this.
|
|
104
|
+
*/
|
|
105
|
+
current?: { tierId: string; status: string } | null;
|
|
83
106
|
}
|
|
84
107
|
|
|
85
108
|
const errMessage = (e: unknown) =>
|
|
@@ -106,8 +129,17 @@ const errMessage = (e: unknown) =>
|
|
|
106
129
|
*/
|
|
107
130
|
export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
108
131
|
const { user, refetchUser } = usePublicAuth();
|
|
132
|
+
const community = useOptionalCommunityContext();
|
|
109
133
|
const inviteToken = opts.inviteToken === undefined ? readMembershipInviteFromUrl() : (opts.inviteToken ?? undefined);
|
|
110
|
-
const
|
|
134
|
+
const joinToken = opts.joinToken ?? undefined;
|
|
135
|
+
const fetched = useGetMembershipTiers({ inviteToken: inviteToken ?? null, enabled: !opts.tiers });
|
|
136
|
+
const tiers = opts.tiers ?? fetched.data;
|
|
137
|
+
const isLoading = opts.tiers ? false : fetched.isLoading;
|
|
138
|
+
/** The landing paths, inside the community whenever there is one. */
|
|
139
|
+
const homeFor = (query?: { welcome?: 1; confirmSubscription?: 1 }) =>
|
|
140
|
+
community?.slug ? communityHomePath(community.slug, query) : communitiesListPath();
|
|
141
|
+
const paidReturnPath = opts.returnPath ?? homeFor({ confirmSubscription: 1 });
|
|
142
|
+
const freeReturnPath = opts.freeReturnPath ?? homeFor({ welcome: 1 });
|
|
111
143
|
const createPaid = useCreateSubscription();
|
|
112
144
|
const createFree = useCreateFreeSubscription();
|
|
113
145
|
const confirmLatest = useConfirmLatestSubscription();
|
|
@@ -224,16 +256,29 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
224
256
|
[selectedTier, billingCycle, customAmount],
|
|
225
257
|
);
|
|
226
258
|
|
|
259
|
+
/**
|
|
260
|
+
* The membership this checkout acts on. A fan holds one membership PER
|
|
261
|
+
* community, so inside a `CommunityProvider` only the caller's `current` (the
|
|
262
|
+
* viewer's membership in THAT community, as `useCommunityJoin` and the styled
|
|
263
|
+
* `MembershipCheckout` pass it) may name one. Without it the fan is treated as
|
|
264
|
+
* holding none here: `user.membership` is the newest membership on the whole
|
|
265
|
+
* profile and may belong to another community, where it would turn this join
|
|
266
|
+
* into a tier change. The server decides per community either way.
|
|
267
|
+
* `user.membership` remains the fallback only outside any community.
|
|
268
|
+
*/
|
|
269
|
+
const current = opts.current !== undefined ? opts.current : community?.communityId ? null : undefined;
|
|
227
270
|
const membership = user?.membership;
|
|
228
271
|
/** The tier they are on now, so its card can say so instead of offering itself. */
|
|
229
|
-
const currentTierId = membership?.membershipTierId;
|
|
272
|
+
const currentTierId = current !== undefined ? (current?.tierId ?? undefined) : membership?.membershipTierId;
|
|
230
273
|
/**
|
|
231
274
|
* A member with a live provider subscription picking a PAID tier is changing,
|
|
232
275
|
* not joining. The server settles it by proration on the subscription they
|
|
233
276
|
* already have, so the button says so.
|
|
234
277
|
*/
|
|
235
278
|
const isChange =
|
|
236
|
-
|
|
279
|
+
current !== undefined
|
|
280
|
+
? !!current && current.status === "active" && !isFreeCycle && !!selectedTier
|
|
281
|
+
: !!membership?.paymentProviderSubscriptionId && membership.status === "active" && !isFreeCycle && !!selectedTier;
|
|
237
282
|
|
|
238
283
|
/**
|
|
239
284
|
* The free trial this fan would start on the selected tier, in days, or 0.
|
|
@@ -264,9 +309,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
264
309
|
}
|
|
265
310
|
if (typeof window !== "undefined") {
|
|
266
311
|
const origin = window.location.origin;
|
|
267
|
-
const path = requiresConfirmation
|
|
268
|
-
? (opts.returnPath ?? "/i/account?tab=membership&confirmSubscription=true")
|
|
269
|
-
: (opts.freeReturnPath ?? "/i/account?tab=membership");
|
|
312
|
+
const path = requiresConfirmation ? paidReturnPath : freeReturnPath;
|
|
270
313
|
window.location.href = `${origin}${path}`;
|
|
271
314
|
}
|
|
272
315
|
};
|
|
@@ -275,7 +318,26 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
275
318
|
const questionnaireBody = () =>
|
|
276
319
|
questionnaire.length ? { questionnaire: buildQuestionnaireAnswers(questionnaire, questionnaireAnswers) } : {};
|
|
277
320
|
|
|
278
|
-
|
|
321
|
+
/**
|
|
322
|
+
* How many join calls are running. `isProcessing` reads THIS, not the
|
|
323
|
+
* mutations' `isPending`: `subscribe` is started from an effect by the join
|
|
324
|
+
* sheet, and a mutation started from an effect loses its observer when React
|
|
325
|
+
* StrictMode unmounts and remounts in development, leaving `isPending` stuck
|
|
326
|
+
* true after the request answered. A settled promise always clears this.
|
|
327
|
+
*/
|
|
328
|
+
const [inFlight, setInFlight] = useState(0);
|
|
329
|
+
const tracked =
|
|
330
|
+
<A extends unknown[]>(run: (...args: A) => Promise<void>) =>
|
|
331
|
+
async (...args: A) => {
|
|
332
|
+
setInFlight((n) => n + 1);
|
|
333
|
+
try {
|
|
334
|
+
await run(...args);
|
|
335
|
+
} finally {
|
|
336
|
+
setInFlight((n) => n - 1);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const subscribe = tracked(async () => {
|
|
279
341
|
setError(null);
|
|
280
342
|
setAmountRefusal(null);
|
|
281
343
|
setTrial(null);
|
|
@@ -302,10 +364,10 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
302
364
|
await submitApplication(origin);
|
|
303
365
|
return;
|
|
304
366
|
}
|
|
305
|
-
const returnUrl = `${origin}${
|
|
367
|
+
const returnUrl = `${origin}${paidReturnPath}`;
|
|
306
368
|
const attributionRefId = readAttributionRef() ?? undefined;
|
|
307
369
|
const landing = readLanding() ?? {};
|
|
308
|
-
const invite = inviteToken ? { inviteToken } : {};
|
|
370
|
+
const invite = { ...(inviteToken ? { inviteToken } : {}), ...(joinToken ? { joinToken } : {}) };
|
|
309
371
|
const answers = questionnaireBody();
|
|
310
372
|
try {
|
|
311
373
|
if (isFreeCycle) {
|
|
@@ -371,7 +433,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
371
433
|
} catch (e) {
|
|
372
434
|
setError(errMessage(e));
|
|
373
435
|
}
|
|
374
|
-
};
|
|
436
|
+
});
|
|
375
437
|
|
|
376
438
|
/**
|
|
377
439
|
* Apply to an `application` tier. Free: the application is lodged and the
|
|
@@ -379,14 +441,16 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
379
441
|
* moves to "payment" and the renderer saves the card; `confirmApplication()`
|
|
380
442
|
* runs after it succeeds. Nothing is charged until the business approves.
|
|
381
443
|
*/
|
|
382
|
-
const applicationReturnBase = opts.applicationReturnPath ??
|
|
383
|
-
const submitApplication = async (origin: string) => {
|
|
444
|
+
const applicationReturnBase = opts.applicationReturnPath ?? homeFor();
|
|
445
|
+
const submitApplication = tracked(async (origin: string) => {
|
|
384
446
|
if (!selectedTier) return;
|
|
385
447
|
try {
|
|
386
448
|
const data = await applyMutation.mutateAsync({
|
|
387
449
|
membershipTierId: selectedTier.id,
|
|
388
450
|
...(isFreeCycle ? {} : { billingCycle, amount }),
|
|
389
451
|
...questionnaireBody(),
|
|
452
|
+
...(inviteToken ? { inviteToken } : {}),
|
|
453
|
+
...(joinToken ? { joinToken } : {}),
|
|
390
454
|
returnUrl: `${origin}${applicationReturnBase}`,
|
|
391
455
|
});
|
|
392
456
|
setApplication(data.application);
|
|
@@ -398,10 +462,10 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
398
462
|
} catch (e) {
|
|
399
463
|
setError(errMessage(e));
|
|
400
464
|
}
|
|
401
|
-
};
|
|
465
|
+
});
|
|
402
466
|
|
|
403
467
|
/** Call after the card renderer confirmed the SetupIntent. Idempotent on the server. */
|
|
404
|
-
const confirmApplication = async () => {
|
|
468
|
+
const confirmApplication = tracked(async () => {
|
|
405
469
|
if (!application) return;
|
|
406
470
|
setError(null);
|
|
407
471
|
setCardConfirmed(true);
|
|
@@ -411,7 +475,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
411
475
|
} catch (e) {
|
|
412
476
|
setError(errMessage(e));
|
|
413
477
|
}
|
|
414
|
-
};
|
|
478
|
+
});
|
|
415
479
|
|
|
416
480
|
const applicationOutcome: MembershipApplicationOutcome | null = membershipApplicationOutcome(
|
|
417
481
|
application,
|
|
@@ -478,8 +542,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
478
542
|
onError: (message) => setError(message ?? "The payment could not be completed. Please try again."),
|
|
479
543
|
})
|
|
480
544
|
: Promise.resolve("unavailable" as PaystackCheckoutOutcome),
|
|
481
|
-
isProcessing:
|
|
482
|
-
createPaid.isPending || createFree.isPending || applyMutation.isPending || confirmApplicationMutation.isPending,
|
|
545
|
+
isProcessing: inFlight > 0,
|
|
483
546
|
error,
|
|
484
547
|
/** Call on the return page to reconcile the latest subscription. */
|
|
485
548
|
confirmLatest,
|
|
@@ -487,6 +550,10 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
487
550
|
finish,
|
|
488
551
|
/** The invite token this checkout forwards, if any. */
|
|
489
552
|
inviteToken,
|
|
553
|
+
/** The shareable link token this checkout forwards, if any. */
|
|
554
|
+
joinToken,
|
|
555
|
+
/** Where a paid subscription returns: the community home with `confirmSubscription=1` by default. */
|
|
556
|
+
returnPath: paidReturnPath,
|
|
490
557
|
/** True when subscribe() applies to the tier instead of joining it. */
|
|
491
558
|
isApplication,
|
|
492
559
|
/**
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
2
|
import type { PublicMembershipGate } from "../../../types/models";
|
|
3
3
|
import { useGetMembershipTiers } from "../../../data/queries/useMembership";
|
|
4
|
+
import { usePublicCommunities } from "../../../data/queries/useCommunities";
|
|
5
|
+
import { communityAboutPath } from "../communities/paths";
|
|
4
6
|
import {
|
|
5
7
|
buildMembershipGateNotice,
|
|
6
8
|
isMembershipGateLocked,
|
|
@@ -13,11 +15,15 @@ export interface UseMembershipGateNoticeInput {
|
|
|
13
15
|
gate?: PublicMembershipGate | null;
|
|
14
16
|
/** A `MEMBERSHIP_GATE` refusal from a failed purchase. Wins over `gate`. */
|
|
15
17
|
refusal?: MembershipGateRefusal | null;
|
|
16
|
-
/** What the thing is, in the buyer's words
|
|
18
|
+
/** What the thing is, in the buyer's words: "This ticket", "This course". */
|
|
17
19
|
itemLabel?: string;
|
|
18
20
|
/** Where a signed-out visitor signs in. Code sites mount theirs under `/i`. */
|
|
19
21
|
loginPath?: string;
|
|
20
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Where a signed-in non-member goes to join. Default: the About page of the
|
|
24
|
+
* community the first required tier belongs to, with that tier preselected
|
|
25
|
+
* (`/i/communities/<slug>?tier=<id>`), which joins in place.
|
|
26
|
+
*/
|
|
21
27
|
membershipPath?: string;
|
|
22
28
|
}
|
|
23
29
|
|
|
@@ -29,14 +35,14 @@ export interface UseMembershipGateNoticeInput {
|
|
|
29
35
|
* A gate comes down as `requiredTierIds`, and "you need tier 8f3c-…" is not a
|
|
30
36
|
* sentence anyone can act on. The public tier list is already loaded by every
|
|
31
37
|
* storefront that sells memberships, is cached by React Query under one key per
|
|
32
|
-
* profile, and
|
|
38
|
+
* profile, and (unlike an id embedded in a ticket payload) stays correct when
|
|
33
39
|
* the artist renames a tier. So the ids are resolved against it here, once, for
|
|
34
40
|
* every surface.
|
|
35
41
|
*
|
|
36
42
|
* The names are best-effort by design: the query can be in flight, and a gating
|
|
37
43
|
* tier can be one the public list does not carry (an archived or unlisted tier
|
|
38
44
|
* still gates). `buildMembershipGateNotice` therefore degrades to "is for
|
|
39
|
-
* members" rather than showing an id or rendering nothing
|
|
45
|
+
* members" rather than showing an id or rendering nothing: a badge with slightly
|
|
40
46
|
* vaguer wording is a working funnel; a blank space is the bug this closes.
|
|
41
47
|
*
|
|
42
48
|
* Returns `null` when nothing is gated, which is also what every surface sees
|
|
@@ -44,22 +50,33 @@ export interface UseMembershipGateNoticeInput {
|
|
|
44
50
|
*/
|
|
45
51
|
export function useMembershipGateNotice(input: UseMembershipGateNoticeInput): MembershipGateNotice | null {
|
|
46
52
|
const locked = isMembershipGateLocked(input.gate) || !!input.refusal;
|
|
47
|
-
// Only pulled once something is actually locked
|
|
53
|
+
// Only pulled once something is actually locked: an ungated storefront must
|
|
48
54
|
// not acquire a request it never needed.
|
|
49
|
-
|
|
55
|
+
// Profile-wide on purpose: a gate on a product can name a tier from any community.
|
|
56
|
+
const { data: tiers } = useGetMembershipTiers({ enabled: locked, communityId: null });
|
|
57
|
+
const { data: communities } = usePublicCommunities({ enabled: locked && !input.membershipPath });
|
|
50
58
|
|
|
51
59
|
const requiredTierIds = input.refusal?.requiredTierIds ?? input.gate?.requiredTierIds ?? [];
|
|
52
60
|
|
|
53
61
|
return useMemo(() => {
|
|
54
62
|
if (!locked) return null;
|
|
55
63
|
const byId = new Map((tiers ?? []).map((tier) => [tier.id, tier.name]));
|
|
64
|
+
// A tier belongs to exactly one community: its About page is where to join it.
|
|
65
|
+
const firstTier = (tiers ?? []).find((tier) => tier.id === requiredTierIds[0]) as
|
|
66
|
+
| { id: string; communityId?: string }
|
|
67
|
+
| undefined;
|
|
68
|
+
const community = firstTier?.communityId
|
|
69
|
+
? communities?.find((candidate) => candidate.id === firstTier.communityId)
|
|
70
|
+
: undefined;
|
|
71
|
+
const membershipPath =
|
|
72
|
+
input.membershipPath ?? (community ? communityAboutPath(community.slug, { tier: firstTier!.id }) : undefined);
|
|
56
73
|
return buildMembershipGateNotice({
|
|
57
74
|
gate: input.gate,
|
|
58
75
|
refusal: input.refusal,
|
|
59
76
|
tierNames: requiredTierIds.map((id) => byId.get(id) ?? "").filter(Boolean),
|
|
60
77
|
itemLabel: input.itemLabel,
|
|
61
78
|
loginPath: input.loginPath,
|
|
62
|
-
membershipPath
|
|
79
|
+
membershipPath,
|
|
63
80
|
currentPath: currentPath(),
|
|
64
81
|
});
|
|
65
82
|
// `requiredTierIds` is a fresh array each render; its CONTENTS are the input.
|
|
@@ -67,6 +84,7 @@ export function useMembershipGateNotice(input: UseMembershipGateNoticeInput): Me
|
|
|
67
84
|
}, [
|
|
68
85
|
locked,
|
|
69
86
|
tiers,
|
|
87
|
+
communities,
|
|
70
88
|
requiredTierIds.join(","),
|
|
71
89
|
input.gate?.reason,
|
|
72
90
|
input.refusal?.reason,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Client portal (`/i/account/*`) headless primitives.
|
|
2
|
+
export {
|
|
3
|
+
usePortalSummary,
|
|
4
|
+
usePortalInvoices,
|
|
5
|
+
usePortalDocuments,
|
|
6
|
+
usePortalActivity,
|
|
7
|
+
useWorkApprovalRequests,
|
|
8
|
+
useDecideApproval,
|
|
9
|
+
type PortalSectionKey,
|
|
10
|
+
type PortalAttentionItem,
|
|
11
|
+
type PortalSummary,
|
|
12
|
+
type PortalInvoice,
|
|
13
|
+
type PortalDocument,
|
|
14
|
+
type PortalActivityKind,
|
|
15
|
+
type PortalActivityItem,
|
|
16
|
+
type PortalActivityPage,
|
|
17
|
+
type ApprovalStatus,
|
|
18
|
+
type WorkApprovalRequest,
|
|
19
|
+
type WorkApprovalRequests,
|
|
20
|
+
type ApprovalDecision,
|
|
21
|
+
} from "./usePortal";
|
|
22
|
+
export {
|
|
23
|
+
usePortalFiles,
|
|
24
|
+
portalFilesKey,
|
|
25
|
+
type PortalFile,
|
|
26
|
+
type PortalFileOpen,
|
|
27
|
+
type PortalFilesPage,
|
|
28
|
+
type PortalFileSource,
|
|
29
|
+
type PortalFileSourceFilter,
|
|
30
|
+
} from "./usePortalFiles";
|
|
31
|
+
// Preview as client: redeem the one-time code, and read whether this is a preview.
|
|
32
|
+
export {
|
|
33
|
+
usePortalPreview,
|
|
34
|
+
usePortalPreviewRedeem,
|
|
35
|
+
isPortalPreviewPending,
|
|
36
|
+
type PortalPreview,
|
|
37
|
+
type PortalPreviewRedeemState,
|
|
38
|
+
} from "./usePortalPreview";
|
|
39
|
+
// Messages: the one conversation with the creator.
|
|
40
|
+
export {
|
|
41
|
+
usePortalThread,
|
|
42
|
+
usePortalMessages,
|
|
43
|
+
useSendPortalMessage,
|
|
44
|
+
useMarkPortalMessagesRead,
|
|
45
|
+
usePortalMessageAttachments,
|
|
46
|
+
usePortalMessagesLive,
|
|
47
|
+
portalMessagesInOrder,
|
|
48
|
+
portalThreadKey,
|
|
49
|
+
portalMessagesKey,
|
|
50
|
+
PORTAL_MESSAGES_POLL_MS,
|
|
51
|
+
PORTAL_MESSAGES_PRESIGN_PATH,
|
|
52
|
+
type PortalMessage,
|
|
53
|
+
type PortalMessageAttachment,
|
|
54
|
+
type PortalMessagesPage,
|
|
55
|
+
type PortalThreadState,
|
|
56
|
+
type PortalThreadSummary,
|
|
57
|
+
} from "./usePortalMessages";
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { useInfiniteQuery, useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
|
|
2
|
+
import { useForge } from "../../../provider/ForgeProvider";
|
|
3
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Headless data layer for the client portal (`/i/account/*`).
|
|
7
|
+
*
|
|
8
|
+
* Speaks `/public/portal/*`, which answers for the signed-in account on this
|
|
9
|
+
* profile only: every list is "mine", and nothing here takes an account id. The
|
|
10
|
+
* styled portal is built on exactly these, so a creator can build a different
|
|
11
|
+
* portal on the same data.
|
|
12
|
+
*
|
|
13
|
+
* Contract: docs/proposals/unified-client-portal.md, "Backend contract".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ── Types (mirror PortalService responses) ───────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export type PortalSectionKey =
|
|
19
|
+
| "projects"
|
|
20
|
+
| "invoices"
|
|
21
|
+
| "documents"
|
|
22
|
+
| "bookings"
|
|
23
|
+
| "memberships"
|
|
24
|
+
| "courses"
|
|
25
|
+
| "films"
|
|
26
|
+
| "orders"
|
|
27
|
+
| "tickets"
|
|
28
|
+
| "waitlist"
|
|
29
|
+
| "saved"
|
|
30
|
+
/** 1 when the person may message the creator, else 0. */
|
|
31
|
+
| "messages"
|
|
32
|
+
| "files";
|
|
33
|
+
|
|
34
|
+
export type PortalAttentionItem =
|
|
35
|
+
| {
|
|
36
|
+
kind: "invoice_due" | "invoice_overdue";
|
|
37
|
+
id: string;
|
|
38
|
+
invoiceNumber: string;
|
|
39
|
+
total: string;
|
|
40
|
+
currency: string;
|
|
41
|
+
dueDate: string | null;
|
|
42
|
+
projectId: string | null;
|
|
43
|
+
projectName: string | null;
|
|
44
|
+
}
|
|
45
|
+
| { kind: "document_to_sign"; id: string; title: string; token: string; sentAt: string; expiresAt: string | null }
|
|
46
|
+
| {
|
|
47
|
+
kind: "approval_requested";
|
|
48
|
+
id: string;
|
|
49
|
+
milestoneId: string;
|
|
50
|
+
milestoneName: string;
|
|
51
|
+
projectId: string;
|
|
52
|
+
projectName: string;
|
|
53
|
+
requestedAt: string;
|
|
54
|
+
note: string | null;
|
|
55
|
+
}
|
|
56
|
+
| { kind: "membership_payment_failed"; id: string; communityName: string; communitySlug: string }
|
|
57
|
+
| {
|
|
58
|
+
kind: "message_unread";
|
|
59
|
+
/** The conversation's id. */
|
|
60
|
+
id: string;
|
|
61
|
+
unreadCount: number;
|
|
62
|
+
lastMessageAt: string;
|
|
63
|
+
preview: string | null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export interface PortalSummary {
|
|
67
|
+
sections: Record<PortalSectionKey, number>;
|
|
68
|
+
/** The newest thing that happened in each group. Orders the navigation. */
|
|
69
|
+
lastActivity: { work: string | null; library: string | null };
|
|
70
|
+
/** Already ranked by the server: overdue first, then approvals, documents, due. */
|
|
71
|
+
attention: PortalAttentionItem[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface PortalInvoice {
|
|
75
|
+
id: string;
|
|
76
|
+
invoiceNumber: string;
|
|
77
|
+
status: string;
|
|
78
|
+
total: string;
|
|
79
|
+
currency: string;
|
|
80
|
+
issueDate: string;
|
|
81
|
+
dueDate: string | null;
|
|
82
|
+
paidAt: string | null;
|
|
83
|
+
projectId: string | null;
|
|
84
|
+
projectName: string | null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface PortalDocument {
|
|
88
|
+
documentId: string;
|
|
89
|
+
title: string;
|
|
90
|
+
kind: string;
|
|
91
|
+
documentStatus: string;
|
|
92
|
+
recipientStatus: string;
|
|
93
|
+
needsAction: boolean;
|
|
94
|
+
/** The recipient's own access token: the key to `/i/documents/$token`. */
|
|
95
|
+
token: string;
|
|
96
|
+
sentAt: string;
|
|
97
|
+
completedAt: string | null;
|
|
98
|
+
expiresAt: string | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type PortalActivityKind =
|
|
102
|
+
| "invoice_issued"
|
|
103
|
+
| "invoice_paid"
|
|
104
|
+
| "document_sent"
|
|
105
|
+
| "document_signed"
|
|
106
|
+
| "milestone_completed"
|
|
107
|
+
| "comment_added"
|
|
108
|
+
| "approval_requested"
|
|
109
|
+
| "approval_decided"
|
|
110
|
+
| "order_placed"
|
|
111
|
+
| "booking_confirmed"
|
|
112
|
+
| "message_received";
|
|
113
|
+
|
|
114
|
+
export interface PortalActivityItem {
|
|
115
|
+
id: string;
|
|
116
|
+
kind: PortalActivityKind;
|
|
117
|
+
occurredAt: string;
|
|
118
|
+
title: string;
|
|
119
|
+
projectId: string | null;
|
|
120
|
+
projectName: string | null;
|
|
121
|
+
entityId: string;
|
|
122
|
+
detail: string | null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface PortalActivityPage {
|
|
126
|
+
items: PortalActivityItem[];
|
|
127
|
+
nextBefore: string | null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type ApprovalStatus = "pending" | "approved" | "changes_requested" | "cancelled";
|
|
131
|
+
|
|
132
|
+
export interface WorkApprovalRequest {
|
|
133
|
+
id: string;
|
|
134
|
+
milestoneId: string;
|
|
135
|
+
milestoneName: string;
|
|
136
|
+
status: ApprovalStatus;
|
|
137
|
+
note: string | null;
|
|
138
|
+
requestedAt: string;
|
|
139
|
+
decidedAt: string | null;
|
|
140
|
+
decisionComment: string | null;
|
|
141
|
+
decidedByName: string | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type ApprovalDecision = "approve" | "request_changes";
|
|
145
|
+
|
|
146
|
+
// ── Keys ─────────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
const keys = {
|
|
149
|
+
summary: (profileId?: string, accountId?: string) => ["portal-summary", profileId, accountId] as const,
|
|
150
|
+
invoices: (profileId?: string, accountId?: string, status?: string) =>
|
|
151
|
+
["portal-invoices", profileId, accountId, status] as const,
|
|
152
|
+
documents: (profileId?: string, accountId?: string) => ["portal-documents", profileId, accountId] as const,
|
|
153
|
+
activity: (profileId?: string, accountId?: string, limit?: number) =>
|
|
154
|
+
["portal-activity", profileId, accountId, limit] as const,
|
|
155
|
+
approvals: (profileId?: string, projectId?: string) => ["work-approvals", profileId, projectId] as const,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// ── Hooks ────────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/** What needs the person, and which sections they have anything in. */
|
|
161
|
+
export function usePortalSummary(): UseQueryResult<PortalSummary> {
|
|
162
|
+
const { client, profileId } = useForge();
|
|
163
|
+
const { user } = usePublicAuth();
|
|
164
|
+
return useQuery({
|
|
165
|
+
queryKey: keys.summary(profileId, user?.id),
|
|
166
|
+
queryFn: async () => {
|
|
167
|
+
const res = await client.get("/public/portal/summary", { params: { profileId } });
|
|
168
|
+
return res.data as PortalSummary;
|
|
169
|
+
},
|
|
170
|
+
enabled: !!profileId && !!client && !!user,
|
|
171
|
+
// The rail and the bottom bar read this on every portal page; a person
|
|
172
|
+
// moving between sections should not watch the navigation reload.
|
|
173
|
+
staleTime: 30_000,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Every invoice the creator has sent this person, newest first. */
|
|
178
|
+
export function usePortalInvoices(status: "open" | "paid" | "all" = "all", pageSize = 50) {
|
|
179
|
+
const { client, profileId } = useForge();
|
|
180
|
+
const { user } = usePublicAuth();
|
|
181
|
+
return useQuery({
|
|
182
|
+
queryKey: keys.invoices(profileId, user?.id, `${status}:${pageSize}`),
|
|
183
|
+
queryFn: async () => {
|
|
184
|
+
const res = await client.get("/public/portal/invoices", { params: { profileId, status, page: 1, pageSize } });
|
|
185
|
+
return res.data as { data: PortalInvoice[]; total: number; page: number; pageSize: number };
|
|
186
|
+
},
|
|
187
|
+
enabled: !!profileId && !!client && !!user,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Documents sent to this person for signature or acceptance. */
|
|
192
|
+
export function usePortalDocuments(): UseQueryResult<PortalDocument[]> {
|
|
193
|
+
const { client, profileId } = useForge();
|
|
194
|
+
const { user } = usePublicAuth();
|
|
195
|
+
return useQuery({
|
|
196
|
+
queryKey: keys.documents(profileId, user?.id),
|
|
197
|
+
queryFn: async () => {
|
|
198
|
+
const res = await client.get("/public/portal/documents", { params: { profileId } });
|
|
199
|
+
return (res.data as { data: PortalDocument[] }).data;
|
|
200
|
+
},
|
|
201
|
+
enabled: !!profileId && !!client && !!user,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The activity timeline, newest first, one page at a time. */
|
|
206
|
+
export function usePortalActivity(limit = 20) {
|
|
207
|
+
const { client, profileId } = useForge();
|
|
208
|
+
const { user } = usePublicAuth();
|
|
209
|
+
return useInfiniteQuery({
|
|
210
|
+
queryKey: keys.activity(profileId, user?.id, limit),
|
|
211
|
+
initialPageParam: undefined as string | undefined,
|
|
212
|
+
queryFn: async ({ pageParam }) => {
|
|
213
|
+
const res = await client.get("/public/portal/activity", {
|
|
214
|
+
params: { profileId, limit, ...(pageParam ? { before: pageParam } : {}) },
|
|
215
|
+
});
|
|
216
|
+
return res.data as PortalActivityPage;
|
|
217
|
+
},
|
|
218
|
+
getNextPageParam: (last) => last.nextBefore ?? undefined,
|
|
219
|
+
enabled: !!profileId && !!client && !!user,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface WorkApprovalRequests {
|
|
224
|
+
/** Newest first, every status: the project's approval history. */
|
|
225
|
+
requests: WorkApprovalRequest[];
|
|
226
|
+
/**
|
|
227
|
+
* Whether THIS client may decide. A view-only seat reads the same history but
|
|
228
|
+
* must not be offered Approve, which the decision endpoint would refuse.
|
|
229
|
+
*/
|
|
230
|
+
canApprove: boolean;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Every approval the creator has asked of this client on one project, and whether they may decide. */
|
|
234
|
+
export function useWorkApprovalRequests(projectId: string): UseQueryResult<WorkApprovalRequests> {
|
|
235
|
+
const { client, profileId } = useForge();
|
|
236
|
+
return useQuery({
|
|
237
|
+
queryKey: keys.approvals(profileId, projectId),
|
|
238
|
+
queryFn: async () => {
|
|
239
|
+
const res = await client.get(`/public/work/projects/${projectId}/approval-requests`, { params: { profileId } });
|
|
240
|
+
const body = res.data as { data: WorkApprovalRequest[]; canApprove?: boolean };
|
|
241
|
+
// An API build older than the flag decided nothing here; treat it as
|
|
242
|
+
// "may approve", which is exactly how the page behaved before.
|
|
243
|
+
return { requests: body.data, canApprove: body.canApprove ?? true };
|
|
244
|
+
},
|
|
245
|
+
enabled: !!profileId && !!client && !!projectId,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Approve a milestone, or send it back with a comment.
|
|
251
|
+
*
|
|
252
|
+
* Approving completes the milestone, so the project report, the summary and the
|
|
253
|
+
* activity timeline all change with it, and all three are refetched.
|
|
254
|
+
*/
|
|
255
|
+
export function useDecideApproval(projectId: string) {
|
|
256
|
+
const { client, profileId } = useForge();
|
|
257
|
+
const queryClient = useQueryClient();
|
|
258
|
+
return useMutation({
|
|
259
|
+
mutationFn: async (input: { requestId: string; decision: ApprovalDecision; comment?: string }) => {
|
|
260
|
+
const res = await client.post(
|
|
261
|
+
`/public/work/projects/${projectId}/approval-requests/${input.requestId}/decision`,
|
|
262
|
+
{ decision: input.decision, comment: input.comment },
|
|
263
|
+
{ params: { profileId } },
|
|
264
|
+
);
|
|
265
|
+
return res.data as WorkApprovalRequest;
|
|
266
|
+
},
|
|
267
|
+
onSuccess: () => {
|
|
268
|
+
void queryClient.invalidateQueries({ queryKey: keys.approvals(profileId, projectId) });
|
|
269
|
+
void queryClient.invalidateQueries({ queryKey: ["portal-summary"] });
|
|
270
|
+
void queryClient.invalidateQueries({ queryKey: ["portal-activity"] });
|
|
271
|
+
void queryClient.invalidateQueries({ queryKey: ["work-portal-report", profileId, projectId] });
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
}
|