@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,68 @@
|
|
|
1
|
+
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
2
|
+
import { useForge } from "../../../provider/ForgeProvider";
|
|
3
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every file this person has with the creator (`GET /public/portal/files`):
|
|
7
|
+
* project files shared with them, attachments on their project tasks, signed
|
|
8
|
+
* documents, invoice PDFs and their paid downloads, newest first across all.
|
|
9
|
+
*
|
|
10
|
+
* A row never carries a signed address. `open` says which endpoint serves it,
|
|
11
|
+
* and that endpoint checks ownership again on the click.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type PortalFileSource = "project" | "task" | "document" | "invoice" | "purchase";
|
|
15
|
+
export type PortalFileSourceFilter = PortalFileSource | "all";
|
|
16
|
+
|
|
17
|
+
export type PortalFileOpen =
|
|
18
|
+
/** Fetch a 60-second URL through `useWorkOpenSharedDocument(projectId)`. */
|
|
19
|
+
| { kind: "project_document"; projectId: string; documentId: string }
|
|
20
|
+
/** A public media address, safe to link directly. */
|
|
21
|
+
| { kind: "task_attachment"; projectId: string; taskId: string; url: string }
|
|
22
|
+
/** The recipient token: `GET /public/documents/:token/download`. */
|
|
23
|
+
| { kind: "signed_document"; token: string }
|
|
24
|
+
/** `GET /public/invoices/:id/pdf`. */
|
|
25
|
+
| { kind: "invoice_pdf"; invoiceId: string }
|
|
26
|
+
/** A paid download: the download page `/i/downloads/<token>`, which lets the owner straight through. */
|
|
27
|
+
| { kind: "purchase_download"; token: string };
|
|
28
|
+
|
|
29
|
+
export interface PortalFile {
|
|
30
|
+
id: string;
|
|
31
|
+
source: PortalFileSource;
|
|
32
|
+
title: string;
|
|
33
|
+
filename: string | null;
|
|
34
|
+
mimeType: string | null;
|
|
35
|
+
size: number | null;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
projectId: string | null;
|
|
38
|
+
projectName: string | null;
|
|
39
|
+
open: PortalFileOpen;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PortalFilesPage {
|
|
43
|
+
data: PortalFile[];
|
|
44
|
+
total: number;
|
|
45
|
+
page: number;
|
|
46
|
+
pageSize: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const portalFilesKey = (profileId?: string, accountId?: string, source?: string, pageSize?: number) =>
|
|
50
|
+
["portal-files", profileId, accountId, source, pageSize] as const;
|
|
51
|
+
|
|
52
|
+
/** Paged newest first. `fetchNextPage` loads the next `pageSize` rows. */
|
|
53
|
+
export function usePortalFiles(source: PortalFileSourceFilter = "all", pageSize = 30) {
|
|
54
|
+
const { client, profileId } = useForge();
|
|
55
|
+
const { user } = usePublicAuth();
|
|
56
|
+
return useInfiniteQuery({
|
|
57
|
+
queryKey: portalFilesKey(profileId, user?.id, source, pageSize),
|
|
58
|
+
initialPageParam: 1,
|
|
59
|
+
queryFn: async ({ pageParam }) => {
|
|
60
|
+
const res = await client.get("/public/portal/files", {
|
|
61
|
+
params: { profileId, source, page: pageParam, pageSize },
|
|
62
|
+
});
|
|
63
|
+
return res.data as PortalFilesPage;
|
|
64
|
+
},
|
|
65
|
+
getNextPageParam: (last) => (last.page * last.pageSize < last.total ? last.page + 1 : undefined),
|
|
66
|
+
enabled: !!profileId && !!client && !!user,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { useEffect, useMemo } from "react";
|
|
2
|
+
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { useForge } from "../../../provider/ForgeProvider";
|
|
4
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
5
|
+
import { useChatSocket } from "../../../data/queries/useChat";
|
|
6
|
+
import { useChatAttachmentUpload } from "../chat/useChatAttachmentUpload";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Messages in the client portal (`/i/account/messages`): one conversation
|
|
10
|
+
* between this person and the creator (`/public/portal/messages`).
|
|
11
|
+
*
|
|
12
|
+
* The creator's replies arrive as the BRAND: `senderName` on a team message is
|
|
13
|
+
* the creator's name, never the team member who wrote it. Realtime rides the
|
|
14
|
+
* community chat socket (`chat:<threadId>`); when the socket is not connected,
|
|
15
|
+
* the open page re-reads every 10 seconds while it is visible.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface PortalMessageAttachment {
|
|
19
|
+
url: string;
|
|
20
|
+
fileName: string;
|
|
21
|
+
fileSize: number;
|
|
22
|
+
fileType: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PortalMessage {
|
|
26
|
+
id: string;
|
|
27
|
+
content: string | null;
|
|
28
|
+
attachments: PortalMessageAttachment[];
|
|
29
|
+
createdAt: string;
|
|
30
|
+
/** A reply from the creator's side. */
|
|
31
|
+
fromTeam: boolean;
|
|
32
|
+
/** The caller's own message. */
|
|
33
|
+
isMine: boolean;
|
|
34
|
+
/** The creator's name on a reply, the person's first name on their own. */
|
|
35
|
+
senderName: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PortalThreadSummary {
|
|
39
|
+
id: string;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
unreadCount: number;
|
|
42
|
+
lastMessageAt: string | null;
|
|
43
|
+
lastPreview: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PortalThreadState {
|
|
47
|
+
/** May this person write (start a conversation, or keep writing in theirs)? */
|
|
48
|
+
allowed: boolean;
|
|
49
|
+
creator: { name: string };
|
|
50
|
+
/** Null until the first message. */
|
|
51
|
+
thread: PortalThreadSummary | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PortalMessagesPage {
|
|
55
|
+
/** Oldest first inside the page. */
|
|
56
|
+
data: PortalMessage[];
|
|
57
|
+
/** Pass as `before` for the page before this one; null at the start. */
|
|
58
|
+
nextCursor: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** How often an open Messages page re-reads while the socket is not connected. */
|
|
62
|
+
export const PORTAL_MESSAGES_POLL_MS = 10_000;
|
|
63
|
+
|
|
64
|
+
/** The endpoint that signs a portal attachment upload. */
|
|
65
|
+
export const PORTAL_MESSAGES_PRESIGN_PATH = "/public/portal/messages/attachments/presigned-url";
|
|
66
|
+
|
|
67
|
+
export const portalThreadKey = (profileId?: string, accountId?: string) =>
|
|
68
|
+
["portal-messages-thread", profileId, accountId] as const;
|
|
69
|
+
export const portalMessagesKey = (profileId?: string, accountId?: string) =>
|
|
70
|
+
["portal-messages", profileId, accountId] as const;
|
|
71
|
+
|
|
72
|
+
/** The conversation's header: whether the person may write, the creator, and the thread. */
|
|
73
|
+
export function usePortalThread() {
|
|
74
|
+
const { client, profileId } = useForge();
|
|
75
|
+
const { user } = usePublicAuth();
|
|
76
|
+
return useQuery({
|
|
77
|
+
queryKey: portalThreadKey(profileId, user?.id),
|
|
78
|
+
queryFn: async () =>
|
|
79
|
+
(await client.get("/public/portal/messages", { params: { profileId } })).data as PortalThreadState,
|
|
80
|
+
enabled: !!profileId && !!client && !!user,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The conversation, newest page first. Flatten with `portalMessagesInOrder`. */
|
|
85
|
+
export function usePortalMessages(limit = 30) {
|
|
86
|
+
const { client, profileId } = useForge();
|
|
87
|
+
const { user } = usePublicAuth();
|
|
88
|
+
return useInfiniteQuery({
|
|
89
|
+
queryKey: portalMessagesKey(profileId, user?.id),
|
|
90
|
+
initialPageParam: undefined as string | undefined,
|
|
91
|
+
queryFn: async ({ pageParam }) =>
|
|
92
|
+
(
|
|
93
|
+
await client.get("/public/portal/messages/messages", {
|
|
94
|
+
params: { profileId, limit, ...(pageParam ? { before: pageParam } : {}) },
|
|
95
|
+
})
|
|
96
|
+
).data as PortalMessagesPage,
|
|
97
|
+
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
|
98
|
+
enabled: !!profileId && !!client && !!user,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Every loaded message, oldest first: the pages are newest first, each oldest first inside. */
|
|
103
|
+
export function portalMessagesInOrder(pages: PortalMessagesPage[] | undefined): PortalMessage[] {
|
|
104
|
+
return [...(pages ?? [])].reverse().flatMap((page) => page.data);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Send one message. The first one opens the conversation. */
|
|
108
|
+
export function useSendPortalMessage() {
|
|
109
|
+
const { client, profileId } = useForge();
|
|
110
|
+
const { user } = usePublicAuth();
|
|
111
|
+
const queryClient = useQueryClient();
|
|
112
|
+
return useMutation({
|
|
113
|
+
mutationFn: async (input: { content: string; attachments: PortalMessageAttachment[] }) =>
|
|
114
|
+
(await client.post("/public/portal/messages/messages", { profileId, ...input })).data as PortalMessage,
|
|
115
|
+
onSuccess: () => {
|
|
116
|
+
queryClient.invalidateQueries({ queryKey: portalMessagesKey(profileId, user?.id) });
|
|
117
|
+
queryClient.invalidateQueries({ queryKey: portalThreadKey(profileId, user?.id) });
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Mark the creator's replies read. */
|
|
123
|
+
export function useMarkPortalMessagesRead() {
|
|
124
|
+
const { client, profileId } = useForge();
|
|
125
|
+
const { user } = usePublicAuth();
|
|
126
|
+
const queryClient = useQueryClient();
|
|
127
|
+
return useMutation({
|
|
128
|
+
mutationFn: async () =>
|
|
129
|
+
(await client.post("/public/portal/messages/read", { profileId })).data as {
|
|
130
|
+
marked: number;
|
|
131
|
+
unreadCount: number;
|
|
132
|
+
},
|
|
133
|
+
onSuccess: (result) => {
|
|
134
|
+
if (!result?.marked) return;
|
|
135
|
+
queryClient.invalidateQueries({ queryKey: portalThreadKey(profileId, user?.id) });
|
|
136
|
+
// The portal home's "needs you" list carries the unread count too.
|
|
137
|
+
queryClient.invalidateQueries({ queryKey: ["portal-summary"] });
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Stage and upload attachments through the portal's own signing endpoint. */
|
|
143
|
+
export function usePortalMessageAttachments() {
|
|
144
|
+
return useChatAttachmentUpload({ presignPath: PORTAL_MESSAGES_PRESIGN_PATH });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Follow the conversation live. Joins `chat:<threadId>` on the chat socket and
|
|
149
|
+
* re-reads on every new message; while the socket is down, re-reads every 10
|
|
150
|
+
* seconds as long as the page is visible.
|
|
151
|
+
*/
|
|
152
|
+
export function usePortalMessagesLive(threadId: string | null | undefined) {
|
|
153
|
+
const { profileId } = useForge();
|
|
154
|
+
const { user } = usePublicAuth();
|
|
155
|
+
const queryClient = useQueryClient();
|
|
156
|
+
const socket = useChatSocket(!!threadId && !!user);
|
|
157
|
+
const keys = useMemo(
|
|
158
|
+
() => [portalMessagesKey(profileId, user?.id), portalThreadKey(profileId, user?.id)],
|
|
159
|
+
[profileId, user?.id],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!threadId) return undefined;
|
|
164
|
+
const refresh = () => keys.forEach((queryKey) => queryClient.invalidateQueries({ queryKey }));
|
|
165
|
+
const join = () => socket?.emit("chatJoinChannel", { channelId: threadId });
|
|
166
|
+
const onMessage = (envelope: { event: string; data: { channelId?: string } }) => {
|
|
167
|
+
if (envelope?.data?.channelId !== threadId) return;
|
|
168
|
+
if (envelope.event === "chatNewMessage") refresh();
|
|
169
|
+
};
|
|
170
|
+
if (socket) {
|
|
171
|
+
if (socket.connected) join();
|
|
172
|
+
socket.on("connect", join);
|
|
173
|
+
socket.on("message", onMessage);
|
|
174
|
+
}
|
|
175
|
+
const poll = setInterval(() => {
|
|
176
|
+
if (socket?.connected) return;
|
|
177
|
+
if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
|
|
178
|
+
refresh();
|
|
179
|
+
}, PORTAL_MESSAGES_POLL_MS);
|
|
180
|
+
return () => {
|
|
181
|
+
clearInterval(poll);
|
|
182
|
+
if (socket) {
|
|
183
|
+
socket.off("connect", join);
|
|
184
|
+
socket.off("message", onMessage);
|
|
185
|
+
socket.emit("chatLeaveChannel", { channelId: threadId });
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}, [threadId, socket, keys, queryClient]);
|
|
189
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useState } from "react";
|
|
3
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { useForge } from "../../../provider/ForgeProvider";
|
|
5
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
6
|
+
import {
|
|
7
|
+
PUBLIC_ACCESS_TOKEN_KEY,
|
|
8
|
+
PUBLIC_REFRESH_TOKEN_KEY,
|
|
9
|
+
clearTokenPair,
|
|
10
|
+
storeTokenPair,
|
|
11
|
+
} from "../../../client/tokenStorage";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Preview as client, the site half.
|
|
15
|
+
*
|
|
16
|
+
* A creator clicks "Preview as client" in the admin and lands on
|
|
17
|
+
* `/i/account?preview=<code>`. The code is single use and lives 60 seconds; it
|
|
18
|
+
* is the only thing in the URL. This file trades it for a read-only, 15-minute
|
|
19
|
+
* session for that client, and exposes what the portal needs to say so.
|
|
20
|
+
*
|
|
21
|
+
* Two hooks, on purpose:
|
|
22
|
+
*
|
|
23
|
+
* - `usePortalPreviewRedeem()` does the exchange. Mount it ONCE, on the portal
|
|
24
|
+
* page wrapper. Mounted twice, the second exchange would spend nothing (the
|
|
25
|
+
* code is stripped from the address bar before the first request leaves) but
|
|
26
|
+
* it is still one job with one owner.
|
|
27
|
+
* - `usePortalPreview()` is read-only state plus `exit()`. Mount it wherever the
|
|
28
|
+
* portal needs to know (the banner, a write button that wants to explain
|
|
29
|
+
* itself). The server refuses every write a preview makes regardless.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const PREVIEW_PARAM = "preview";
|
|
33
|
+
|
|
34
|
+
/** Codes this page has already tried, so a re-run effect never spends one twice. */
|
|
35
|
+
const attempted = new Set<string>();
|
|
36
|
+
let redeeming = false;
|
|
37
|
+
|
|
38
|
+
const readCode = (): string | null => {
|
|
39
|
+
if (typeof window === "undefined") return null;
|
|
40
|
+
return new URLSearchParams(window.location.search).get(PREVIEW_PARAM);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Take the code out of the address bar (and so out of history and any Referer) at once. */
|
|
44
|
+
const stripCode = () => {
|
|
45
|
+
if (typeof window === "undefined") return;
|
|
46
|
+
const url = new URL(window.location.href);
|
|
47
|
+
if (!url.searchParams.has(PREVIEW_PARAM)) return;
|
|
48
|
+
url.searchParams.delete(PREVIEW_PARAM);
|
|
49
|
+
window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* True while a preview code is in the URL or being exchanged. An auth guard
|
|
54
|
+
* checks this AT EFFECT TIME before sending a signed-out visitor to the login
|
|
55
|
+
* page: the visitor is about to be signed in as the client.
|
|
56
|
+
*/
|
|
57
|
+
export const isPortalPreviewPending = (): boolean => redeeming || readCode() !== null;
|
|
58
|
+
|
|
59
|
+
export type PortalPreviewRedeemState = { pending: boolean; failed: boolean };
|
|
60
|
+
|
|
61
|
+
export function usePortalPreviewRedeem(): PortalPreviewRedeemState {
|
|
62
|
+
const { client, profileId, setToken } = useForge();
|
|
63
|
+
const { initialize } = usePublicAuth();
|
|
64
|
+
const queryClient = useQueryClient();
|
|
65
|
+
const [state, setState] = useState<PortalPreviewRedeemState>({ pending: false, failed: false });
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const code = readCode();
|
|
69
|
+
if (!code) return;
|
|
70
|
+
stripCode();
|
|
71
|
+
if (attempted.has(code) || !profileId) return;
|
|
72
|
+
attempted.add(code);
|
|
73
|
+
redeeming = true;
|
|
74
|
+
setState({ pending: true, failed: false });
|
|
75
|
+
|
|
76
|
+
client
|
|
77
|
+
.post("/public/sessions/preview/redeem", { code, profileId })
|
|
78
|
+
.then(async (res) => {
|
|
79
|
+
const { token } = res.data as { token: string };
|
|
80
|
+
// Replace whatever this browser held for the site. The REFRESH token goes
|
|
81
|
+
// too: left behind, an expired preview would refresh into the previous
|
|
82
|
+
// identity instead of ending.
|
|
83
|
+
clearTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY);
|
|
84
|
+
storeTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY, token);
|
|
85
|
+
setToken(token);
|
|
86
|
+
// Nothing cached for the previous identity may show under the client's name.
|
|
87
|
+
queryClient.clear();
|
|
88
|
+
await initialize();
|
|
89
|
+
redeeming = false;
|
|
90
|
+
setState({ pending: false, failed: false });
|
|
91
|
+
})
|
|
92
|
+
.catch(() => {
|
|
93
|
+
redeeming = false;
|
|
94
|
+
setState({ pending: false, failed: true });
|
|
95
|
+
});
|
|
96
|
+
}, [client, profileId, setToken, initialize, queryClient]);
|
|
97
|
+
|
|
98
|
+
return state;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type PortalPreview = {
|
|
102
|
+
/** True while this browser is viewing the portal as a client. */
|
|
103
|
+
active: boolean;
|
|
104
|
+
/** The client being previewed. */
|
|
105
|
+
name: string;
|
|
106
|
+
/** Who started the preview (the creator or admin). */
|
|
107
|
+
previewedByName: string;
|
|
108
|
+
/** ISO time the preview ends. It is never extended. */
|
|
109
|
+
expiresAt: string | null;
|
|
110
|
+
/** End the preview, forget the token, and go to the site's home page. */
|
|
111
|
+
exit: () => Promise<void>;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export function usePortalPreview(): PortalPreview {
|
|
115
|
+
const { client, setToken } = useForge();
|
|
116
|
+
const { user } = usePublicAuth();
|
|
117
|
+
const queryClient = useQueryClient();
|
|
118
|
+
const preview = user?.preview ?? null;
|
|
119
|
+
|
|
120
|
+
const exit = useCallback(async () => {
|
|
121
|
+
try {
|
|
122
|
+
await client.post("/public/sessions/preview/end");
|
|
123
|
+
} catch {
|
|
124
|
+
// Ending is best effort from here: the session dies at its expiry anyway,
|
|
125
|
+
// and the token is dropped from this browser below either way.
|
|
126
|
+
}
|
|
127
|
+
clearTokenPair(PUBLIC_ACCESS_TOKEN_KEY, PUBLIC_REFRESH_TOKEN_KEY);
|
|
128
|
+
setToken(null);
|
|
129
|
+
queryClient.clear();
|
|
130
|
+
if (typeof window !== "undefined") window.location.assign("/");
|
|
131
|
+
}, [client, setToken, queryClient]);
|
|
132
|
+
|
|
133
|
+
const name = [user?.firstName, user?.lastName].filter(Boolean).join(" ").trim() || user?.email || "";
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
active: !!preview,
|
|
137
|
+
name,
|
|
138
|
+
previewedByName: preview?.previewedByName ?? "",
|
|
139
|
+
expiresAt: preview?.expiresAt ?? null,
|
|
140
|
+
exit,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { IPublicPost, MembershipTier } from "../../../types/models";
|
|
2
|
+
import { useOptionalCommunityContext, useCommunityUpgradeOpener } from "../../../contexts/CommunityContext";
|
|
3
|
+
import { communityAboutPath } from "../communities/paths";
|
|
2
4
|
|
|
3
5
|
/** Where a locked post/collection sends the member to unlock it. */
|
|
4
6
|
export interface PostUpsell {
|
|
@@ -6,36 +8,80 @@ export interface PostUpsell {
|
|
|
6
8
|
tierId: string | null;
|
|
7
9
|
/** Resolved tier (name/price) when the tiers list is available. */
|
|
8
10
|
tier: MembershipTier | null;
|
|
9
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* The join link: the community's About page with the tier preselected
|
|
13
|
+
* (`/i/communities/<slug>?tier=<id>`) inside a community, else `checkoutPath`.
|
|
14
|
+
*/
|
|
10
15
|
checkoutHref: string;
|
|
11
|
-
/** Signup-first link for anonymous visitors (redirects
|
|
16
|
+
/** Signup-first link for anonymous visitors (redirects to `checkoutHref` after signup). */
|
|
12
17
|
signupHref: string;
|
|
13
18
|
/** The link to actually use: checkout when authenticated, signup otherwise. */
|
|
14
19
|
href: string;
|
|
20
|
+
/**
|
|
21
|
+
* Opens the community's upgrade sheet on this tier ("Unlock with {tier}")
|
|
22
|
+
* when a community home provides one and the viewer is signed in. Null
|
|
23
|
+
* otherwise: follow `href`.
|
|
24
|
+
*/
|
|
25
|
+
unlock: (() => void) | null;
|
|
15
26
|
}
|
|
16
27
|
|
|
17
28
|
export interface UpsellPathOptions {
|
|
18
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Where a locked item's CTA leads outside a community. Inside one it is the
|
|
31
|
+
* community's About page, which joins in place. Default `/i/communities`.
|
|
32
|
+
*/
|
|
19
33
|
checkoutPath?: string;
|
|
20
34
|
/** Signup path (default `/signup`; site-starter uses `/i/signup`). */
|
|
21
35
|
signupPath?: string;
|
|
36
|
+
/** The community's slug. Filled from `<CommunityProvider>` by the hooks. */
|
|
37
|
+
communitySlug?: string;
|
|
22
38
|
}
|
|
23
39
|
|
|
24
|
-
export const DEFAULT_CHECKOUT_PATH = "/
|
|
40
|
+
export const DEFAULT_CHECKOUT_PATH = "/i/communities";
|
|
25
41
|
export const DEFAULT_SIGNUP_PATH = "/signup";
|
|
26
42
|
|
|
27
|
-
/** Build the locked-content upsell state for a post. Pure
|
|
43
|
+
/** Build the locked-content upsell state for a post. Pure: shared by the feed + item hooks. */
|
|
28
44
|
export function buildPostUpsell(
|
|
29
45
|
post: Pick<IPublicPost, "membershipTiers">,
|
|
30
46
|
tiers: MembershipTier[] | undefined,
|
|
31
47
|
isAuthenticated: boolean,
|
|
32
48
|
opts: UpsellPathOptions = {},
|
|
49
|
+
unlockWith?: ((tierId: string | null) => void) | null,
|
|
33
50
|
): PostUpsell {
|
|
34
|
-
const checkoutPath = opts.checkoutPath ?? DEFAULT_CHECKOUT_PATH;
|
|
35
51
|
const signupPath = opts.signupPath ?? DEFAULT_SIGNUP_PATH;
|
|
36
52
|
const tierId = post.membershipTiers?.[0] ?? null;
|
|
37
53
|
const tier = (tierId && tiers?.find((t) => t.id === tierId)) || null;
|
|
38
|
-
|
|
54
|
+
let checkoutHref: string;
|
|
55
|
+
if (opts.communitySlug) {
|
|
56
|
+
checkoutHref = communityAboutPath(opts.communitySlug, tierId ? { tier: tierId } : undefined);
|
|
57
|
+
} else {
|
|
58
|
+
const checkoutPath = opts.checkoutPath ?? DEFAULT_CHECKOUT_PATH;
|
|
59
|
+
checkoutHref = tierId ? `${checkoutPath}${checkoutPath.includes("?") ? "&" : "?"}tier=${tierId}` : checkoutPath;
|
|
60
|
+
}
|
|
39
61
|
const signupHref = `${signupPath}?redirect=${encodeURIComponent(checkoutHref)}`;
|
|
40
|
-
return {
|
|
62
|
+
return {
|
|
63
|
+
tierId,
|
|
64
|
+
tier,
|
|
65
|
+
checkoutHref,
|
|
66
|
+
signupHref,
|
|
67
|
+
href: isAuthenticated ? checkoutHref : signupHref,
|
|
68
|
+
unlock: unlockWith && isAuthenticated ? () => unlockWith(tierId) : null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The upsell inputs a hook reads from the surrounding community: its slug (for
|
|
74
|
+
* the About page link) and the upgrade sheet opener, when a community home
|
|
75
|
+
* provides one. Explicit options win.
|
|
76
|
+
*/
|
|
77
|
+
export function useUpsellContext(opts: UpsellPathOptions = {}): {
|
|
78
|
+
paths: UpsellPathOptions;
|
|
79
|
+
unlockWith: ((tierId: string | null) => void) | null;
|
|
80
|
+
} {
|
|
81
|
+
const community = useOptionalCommunityContext();
|
|
82
|
+
const opener = useCommunityUpgradeOpener();
|
|
83
|
+
return {
|
|
84
|
+
paths: { ...opts, communitySlug: opts.communitySlug ?? community?.slug },
|
|
85
|
+
unlockWith: opener ? (tierId) => opener.openUpgrade(tierId) : null,
|
|
86
|
+
};
|
|
41
87
|
}
|
|
@@ -3,7 +3,7 @@ import type { IPublicPost, MembershipTier, PostType } from "../../../types/model
|
|
|
3
3
|
import { useGetPostsInfinite, useGetSavedPosts } from "../../../data/queries/usePosts";
|
|
4
4
|
import { useGetMembershipTiers } from "../../../data/queries/useMembership";
|
|
5
5
|
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
6
|
-
import { buildPostUpsell, type PostUpsell, type UpsellPathOptions } from "./upsell";
|
|
6
|
+
import { buildPostUpsell, useUpsellContext, type PostUpsell, type UpsellPathOptions } from "./upsell";
|
|
7
7
|
|
|
8
8
|
export interface UseMemberPostsFeedOptions extends UpsellPathOptions {
|
|
9
9
|
/** Posts per page (default 10). */
|
|
@@ -13,16 +13,17 @@ export interface UseMemberPostsFeedOptions extends UpsellPathOptions {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Headless member posts feed
|
|
16
|
+
* Headless member posts feed: the Forge port of the craft members area's
|
|
17
17
|
* `PostsContent` semantics: gated infinite paging over `GET /public/posts`
|
|
18
18
|
* (the server computes per-post `hasAccess` and ships no media for locked
|
|
19
|
-
* posts
|
|
19
|
+
* posts: never re-gate client-side), debounced search, post-type filter,
|
|
20
20
|
* membership-tier filter, a saved-posts view (`GET /public/posts/saved`),
|
|
21
21
|
* and the locked-post upsell target (which tier unlocks + membership
|
|
22
22
|
* checkout deep link). Bring your own UI.
|
|
23
23
|
*/
|
|
24
24
|
export function useMemberPostsFeed(opts: UseMemberPostsFeedOptions = {}) {
|
|
25
25
|
const { isAuthenticated } = usePublicAuth();
|
|
26
|
+
const upsellContext = useUpsellContext(opts);
|
|
26
27
|
|
|
27
28
|
// ── Filters ────────────────────────────────────────────────────────────────
|
|
28
29
|
const [search, setSearchState] = useState("");
|
|
@@ -70,14 +71,14 @@ export function useMemberPostsFeed(opts: UseMemberPostsFeedOptions = {}) {
|
|
|
70
71
|
);
|
|
71
72
|
const total = savedOnly ? (saved.data?.total ?? 0) : (feed.data?.pages[0]?.total ?? 0);
|
|
72
73
|
|
|
73
|
-
/** Accessible audio posts in feed order
|
|
74
|
+
/** Accessible audio posts in feed order: the play queue for audio playback. */
|
|
74
75
|
const audioPosts = useMemo(
|
|
75
76
|
() => posts.filter((p) => p.type === "audio" && p.hasAccess && p.media?.[0]?.url),
|
|
76
77
|
[posts],
|
|
77
78
|
);
|
|
78
79
|
|
|
79
80
|
/** Locked-post upsell: which tier unlocks it + the checkout/signup deep link. */
|
|
80
|
-
const getUpsell = (post: IPublicPost): PostUpsell => buildPostUpsell(post, tiers, isAuthenticated,
|
|
81
|
+
const getUpsell = (post: IPublicPost): PostUpsell => buildPostUpsell(post, tiers, isAuthenticated, upsellContext.paths, upsellContext.unlockWith);
|
|
81
82
|
|
|
82
83
|
return {
|
|
83
84
|
posts,
|
|
@@ -3,12 +3,12 @@ import type { IPublicPost } from "../../../types/models";
|
|
|
3
3
|
import { useGetPostCollection } from "../../../data/queries/usePostCollections";
|
|
4
4
|
import { useGetMembershipTiers } from "../../../data/queries/useMembership";
|
|
5
5
|
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
6
|
-
import { buildPostUpsell, type PostUpsell, type UpsellPathOptions } from "./upsell";
|
|
6
|
+
import { buildPostUpsell, useUpsellContext, type PostUpsell, type UpsellPathOptions } from "./upsell";
|
|
7
7
|
|
|
8
8
|
export type UsePostCollectionDetailOptions = UpsellPathOptions;
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Headless single-collection drill-in
|
|
11
|
+
* Headless single-collection drill-in: the Forge port of the craft members
|
|
12
12
|
* area's `CollectionsContent` detail semantics over
|
|
13
13
|
* `GET /public/post-collections/:id`. The server gates per-collection AND
|
|
14
14
|
* per-post (`hasAccess`; locked posts ship no media), so a member who can see
|
|
@@ -19,6 +19,7 @@ export type UsePostCollectionDetailOptions = UpsellPathOptions;
|
|
|
19
19
|
*/
|
|
20
20
|
export function usePostCollectionDetail(collectionId: string | undefined, opts: UsePostCollectionDetailOptions = {}) {
|
|
21
21
|
const { isAuthenticated } = usePublicAuth();
|
|
22
|
+
const upsellContext = useUpsellContext(opts);
|
|
22
23
|
const { data: tiers } = useGetMembershipTiers();
|
|
23
24
|
const detailQ = useGetPostCollection(collectionId);
|
|
24
25
|
|
|
@@ -30,7 +31,7 @@ export function usePostCollectionDetail(collectionId: string | undefined, opts:
|
|
|
30
31
|
[collection],
|
|
31
32
|
);
|
|
32
33
|
|
|
33
|
-
/** Accessible audio posts in collection order
|
|
34
|
+
/** Accessible audio posts in collection order: the play queue for audio playback. */
|
|
34
35
|
const audioPosts = useMemo(
|
|
35
36
|
() => posts.filter((p) => p.type === "audio" && p.hasAccess && p.media?.[0]?.url),
|
|
36
37
|
[posts],
|
|
@@ -40,7 +41,7 @@ export function usePostCollectionDetail(collectionId: string | undefined, opts:
|
|
|
40
41
|
const cover = collection?.media?.[0] ?? null;
|
|
41
42
|
|
|
42
43
|
/** Locked-collection upsell (null until the collection loads). */
|
|
43
|
-
const upsell: PostUpsell | null = collection ? buildPostUpsell(collection, tiers, isAuthenticated,
|
|
44
|
+
const upsell: PostUpsell | null = collection ? buildPostUpsell(collection, tiers, isAuthenticated, upsellContext.paths, upsellContext.unlockWith) : null;
|
|
44
45
|
|
|
45
46
|
return {
|
|
46
47
|
collection,
|
|
@@ -51,6 +52,8 @@ export function usePostCollectionDetail(collectionId: string | undefined, opts:
|
|
|
51
52
|
upsell,
|
|
52
53
|
isLoading: detailQ.isLoading,
|
|
53
54
|
isError: detailQ.isError,
|
|
55
|
+
/** The collection does not exist in this community (or is archived). */
|
|
56
|
+
notFound: detailQ.isSuccess && detailQ.data === null,
|
|
54
57
|
isAuthenticated,
|
|
55
58
|
};
|
|
56
59
|
}
|
|
@@ -3,7 +3,7 @@ import type { IPublicPostCollection, MembershipTier } from "../../../types/model
|
|
|
3
3
|
import { useGetPostCollections } from "../../../data/queries/usePostCollections";
|
|
4
4
|
import { useGetMembershipTiers } from "../../../data/queries/useMembership";
|
|
5
5
|
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
6
|
-
import { buildPostUpsell, type PostUpsell, type UpsellPathOptions } from "./upsell";
|
|
6
|
+
import { buildPostUpsell, useUpsellContext, type PostUpsell, type UpsellPathOptions } from "./upsell";
|
|
7
7
|
|
|
8
8
|
export interface UsePostCollectionsListOptions extends UpsellPathOptions {
|
|
9
9
|
/** Debounce for the search input → query round trip (default 500ms, matching the craft members area). */
|
|
@@ -11,15 +11,16 @@ export interface UsePostCollectionsListOptions extends UpsellPathOptions {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* Headless post-collections list
|
|
14
|
+
* Headless post-collections list: the Forge port of the craft members area's
|
|
15
15
|
* `CollectionsContent` list semantics: paginated `GET /public/post-collections`
|
|
16
16
|
* (the server computes per-collection `hasAccess`; a locked collection can be
|
|
17
|
-
* shown but not opened
|
|
17
|
+
* shown but not opened: never re-gate client-side), debounced search, and the
|
|
18
18
|
* locked-collection upsell target (which tier unlocks + membership checkout
|
|
19
19
|
* deep link). Bring your own UI; drill-in uses `usePostCollectionDetail`.
|
|
20
20
|
*/
|
|
21
21
|
export function usePostCollectionsList(opts: UsePostCollectionsListOptions = {}) {
|
|
22
22
|
const { isAuthenticated } = usePublicAuth();
|
|
23
|
+
const upsellContext = useUpsellContext(opts);
|
|
23
24
|
|
|
24
25
|
// ── Search + paging ────────────────────────────────────────────────────────
|
|
25
26
|
const [search, setSearchState] = useState("");
|
|
@@ -61,14 +62,14 @@ export function usePostCollectionsList(opts: UsePostCollectionsListOptions = {})
|
|
|
61
62
|
|
|
62
63
|
/** Locked-collection upsell: which tier unlocks it + the checkout/signup deep link. */
|
|
63
64
|
const getUpsell = (collection: IPublicPostCollection): PostUpsell =>
|
|
64
|
-
buildPostUpsell(collection, tiers, isAuthenticated,
|
|
65
|
+
buildPostUpsell(collection, tiers, isAuthenticated, upsellContext.paths, upsellContext.unlockWith);
|
|
65
66
|
|
|
66
67
|
return {
|
|
67
68
|
collections,
|
|
68
69
|
total,
|
|
69
70
|
isLoading: listQ.isLoading,
|
|
70
71
|
isError: listQ.isError,
|
|
71
|
-
// Paging (numbered, server-side
|
|
72
|
+
// Paging (numbered, server-side: matches the craft pagination).
|
|
72
73
|
page,
|
|
73
74
|
pageSize,
|
|
74
75
|
totalPages,
|