@tribe-nest/forge 1.13.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 +42 -0
- package/src/client/createForgeClient.ts +54 -0
- package/src/content/ForgeContentProvider.tsx +118 -0
- package/src/content/ForgeEditBridge.tsx +134 -0
- package/src/content/editFlag.ts +23 -0
- package/src/content/index.ts +28 -0
- package/src/content/resolve.ts +67 -0
- package/src/content/types.ts +97 -0
- package/src/content/useField.ts +64 -0
- package/src/contexts/AppAuthContext.tsx +185 -0
- package/src/contexts/AudioPlayerContext.tsx +389 -0
- package/src/contexts/CartContext.tsx +131 -0
- package/src/contexts/PublicAuthContext.tsx +333 -0
- package/src/contexts/useAppAdminGuard.ts +56 -0
- package/src/css.d.ts +5 -0
- package/src/data/collectionParams.ts +57 -0
- package/src/data/queries/useAccount.ts +71 -0
- package/src/data/queries/useAccountSettings.ts +82 -0
- package/src/data/queries/useAnalytics.ts +86 -0
- package/src/data/queries/useAuthActions.ts +28 -0
- package/src/data/queries/useBlog.ts +103 -0
- package/src/data/queries/useBlogComments.ts +96 -0
- package/src/data/queries/useBroadcasts.ts +31 -0
- package/src/data/queries/useCertificates.ts +58 -0
- package/src/data/queries/useChat.ts +267 -0
- package/src/data/queries/useCoachingAvailability.ts +69 -0
- package/src/data/queries/useCoachingProducts.ts +67 -0
- package/src/data/queries/useCohortPage.ts +17 -0
- package/src/data/queries/useCollections.ts +211 -0
- package/src/data/queries/useCommunity.ts +794 -0
- package/src/data/queries/useCoupons.ts +20 -0
- package/src/data/queries/useCourseAccess.ts +50 -0
- package/src/data/queries/useCourses.ts +91 -0
- package/src/data/queries/useCurrencies.ts +33 -0
- package/src/data/queries/useDocuments.ts +208 -0
- package/src/data/queries/useDonations.ts +29 -0
- package/src/data/queries/useEngagement.ts +115 -0
- package/src/data/queries/useEvents.ts +69 -0
- package/src/data/queries/useFinalize.ts +109 -0
- package/src/data/queries/useForms.ts +31 -0
- package/src/data/queries/useInvoice.ts +65 -0
- package/src/data/queries/useLeadMagnet.ts +96 -0
- package/src/data/queries/useMembership.ts +20 -0
- package/src/data/queries/useNotifications.ts +267 -0
- package/src/data/queries/useOffers.ts +56 -0
- package/src/data/queries/useOrders.ts +71 -0
- package/src/data/queries/usePWA.ts +101 -0
- package/src/data/queries/usePageActions.ts +36 -0
- package/src/data/queries/usePaymentFlow.ts +80 -0
- package/src/data/queries/usePaymentLink.ts +47 -0
- package/src/data/queries/usePodcast.ts +85 -0
- package/src/data/queries/usePostCollections.ts +48 -0
- package/src/data/queries/usePosts.ts +94 -0
- package/src/data/queries/useProducts.ts +108 -0
- package/src/data/queries/useReplay.ts +19 -0
- package/src/data/queries/useReviews.ts +122 -0
- package/src/data/queries/useShipping.ts +109 -0
- package/src/data/queries/useSubscriptions.ts +66 -0
- package/src/data/queries/useWebsite.ts +128 -0
- package/src/data/queries/useWebsiteAgent.ts +100 -0
- package/src/index.ts +98 -0
- package/src/provider/ForgeAppProvider.tsx +149 -0
- package/src/provider/ForgeProvider.tsx +91 -0
- package/src/provider/SiteConfigProvider.tsx +27 -0
- package/src/server/_tests/buildPwaHead.spec.ts +182 -0
- package/src/server/_tests/buildWebManifest.spec.ts +313 -0
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
- package/src/server/index.ts +260 -0
- package/src/server/jobs.ts +104 -0
- package/src/server/pwa.ts +332 -0
- package/src/types/index.ts +74 -0
- package/src/types/models.ts +1313 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
- package/src/ui/analytics/PageMetaPixel.tsx +92 -0
- package/src/ui/content/Editable.tsx +90 -0
- package/src/ui/format/PriceDisplay.tsx +142 -0
- package/src/ui/format/useFormatCurrency.ts +92 -0
- package/src/ui/headless/agent/useAiAgent.ts +106 -0
- package/src/ui/headless/auth/useLoginFlow.ts +72 -0
- package/src/ui/headless/auth/useSignupForm.ts +85 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
- package/src/ui/headless/chat/attachmentKind.ts +27 -0
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
- package/src/ui/headless/chat/useChatRoom.ts +223 -0
- package/src/ui/headless/chat/useMessageThread.ts +60 -0
- package/src/ui/headless/checkout/useCheckout.ts +438 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
- package/src/ui/headless/community/index.ts +20 -0
- package/src/ui/headless/community/lexical.ts +131 -0
- package/src/ui/headless/community/replyTree.ts +51 -0
- package/src/ui/headless/community/richLexical.ts +314 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
- package/src/ui/headless/community/useCommunityThread.ts +95 -0
- package/src/ui/headless/community/useMentionComposer.ts +105 -0
- package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
- package/src/ui/headless/consent/useCookieConsent.ts +81 -0
- package/src/ui/headless/course/useCourseCheckout.ts +99 -0
- package/src/ui/headless/dialog.tsx +14 -0
- package/src/ui/headless/document/index.ts +7 -0
- package/src/ui/headless/document/useContractSignature.ts +68 -0
- package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
- package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
- package/src/ui/headless/document/useSignaturePad.ts +180 -0
- package/src/ui/headless/donation/Donation.tsx +115 -0
- package/src/ui/headless/donation/DonationContext.tsx +223 -0
- package/src/ui/headless/donation/fees.ts +15 -0
- package/src/ui/headless/donation/index.ts +10 -0
- package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
- package/src/ui/headless/event/useEventCheckout.ts +141 -0
- package/src/ui/headless/forms/useContactForm.ts +58 -0
- package/src/ui/headless/forms/useEmailListForm.ts +75 -0
- package/src/ui/headless/forms/useSectionedForm.ts +256 -0
- package/src/ui/headless/index.ts +60 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
- package/src/ui/headless/memberHome/index.ts +7 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
- package/src/ui/headless/membership/MembershipGate.tsx +39 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
- package/src/ui/headless/offer/Offer.tsx +88 -0
- package/src/ui/headless/offer/OfferContext.tsx +162 -0
- package/src/ui/headless/offer/index.ts +8 -0
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
- package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
- package/src/ui/headless/posts/index.ts +13 -0
- package/src/ui/headless/posts/upsell.ts +41 -0
- package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
- package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
- package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
- package/src/ui/headless/posts/usePostItem.ts +81 -0
- package/src/ui/headless/reviews/index.ts +6 -0
- package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
- package/src/ui/headless/reviews/useReviewForm.ts +108 -0
- package/src/ui/headless/reviews/useReviewsList.ts +79 -0
- package/src/ui/headless/work/index.ts +25 -0
- package/src/ui/headless/work/useWorkPortal.ts +349 -0
- package/src/ui/index.ts +144 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
- package/src/ui/payment/ForgeStripePayment.tsx +171 -0
- package/src/ui/shell/TribeNestApp.tsx +121 -0
- package/src/ui/shell/shellGating.spec.ts +28 -0
- package/src/ui/shell/shellGating.ts +11 -0
- package/src/ui/styled/AccountDashboard.tsx +575 -0
- package/src/ui/styled/AiAgentWidget.tsx +152 -0
- package/src/ui/styled/AudioPlayer.tsx +123 -0
- package/src/ui/styled/BlogCategory.tsx +79 -0
- package/src/ui/styled/BlogComments.tsx +236 -0
- package/src/ui/styled/BlogList.tsx +101 -0
- package/src/ui/styled/BlogPost.tsx +160 -0
- package/src/ui/styled/BlogPostFooter.tsx +33 -0
- package/src/ui/styled/BotProtection.tsx +101 -0
- package/src/ui/styled/Button.tsx +88 -0
- package/src/ui/styled/Cart.tsx +276 -0
- package/src/ui/styled/ChatRoom.tsx +252 -0
- package/src/ui/styled/Checkout.tsx +988 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
- package/src/ui/styled/CoachingBooking.tsx +605 -0
- package/src/ui/styled/CoachingConfirmation.tsx +151 -0
- package/src/ui/styled/CoachingDetail.tsx +99 -0
- package/src/ui/styled/CohortPage.tsx +82 -0
- package/src/ui/styled/ConfirmSubscription.tsx +134 -0
- package/src/ui/styled/Confirmation.tsx +171 -0
- package/src/ui/styled/ContactForm.tsx +81 -0
- package/src/ui/styled/CookieConsent.tsx +239 -0
- package/src/ui/styled/CourseAccess.tsx +169 -0
- package/src/ui/styled/CourseCheckout.tsx +425 -0
- package/src/ui/styled/CourseConfirmation.tsx +191 -0
- package/src/ui/styled/CourseDetail.tsx +140 -0
- package/src/ui/styled/CoursesGrid.tsx +85 -0
- package/src/ui/styled/CurrencySwitcher.tsx +51 -0
- package/src/ui/styled/DonationButton.tsx +247 -0
- package/src/ui/styled/DonationPage.tsx +29 -0
- package/src/ui/styled/EmailListForm.tsx +88 -0
- package/src/ui/styled/EventConfirmation.tsx +228 -0
- package/src/ui/styled/EventCountdown.tsx +98 -0
- package/src/ui/styled/EventDetail.tsx +141 -0
- package/src/ui/styled/EventTickets.tsx +548 -0
- package/src/ui/styled/EventsList.tsx +83 -0
- package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
- package/src/ui/styled/FormRenderer.tsx +249 -0
- package/src/ui/styled/InstallBanner.tsx +114 -0
- package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
- package/src/ui/styled/InvoicePayment.tsx +141 -0
- package/src/ui/styled/LeadMagnet.tsx +93 -0
- package/src/ui/styled/Loading.tsx +56 -0
- package/src/ui/styled/LoginForm.tsx +134 -0
- package/src/ui/styled/Markdown.tsx +103 -0
- package/src/ui/styled/MembershipCheckout.tsx +197 -0
- package/src/ui/styled/MembershipTiers.tsx +115 -0
- package/src/ui/styled/OfferButton.tsx +181 -0
- package/src/ui/styled/PageActions.tsx +124 -0
- package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
- package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
- package/src/ui/styled/Paywall.tsx +54 -0
- package/src/ui/styled/PlayButton.tsx +48 -0
- package/src/ui/styled/PodcastEpisode.tsx +137 -0
- package/src/ui/styled/PodcastList.tsx +58 -0
- package/src/ui/styled/PodcastShow.tsx +164 -0
- package/src/ui/styled/PostsFeed.tsx +63 -0
- package/src/ui/styled/ProductDetail.tsx +616 -0
- package/src/ui/styled/ProductGrid.tsx +82 -0
- package/src/ui/styled/PushOptIn.tsx +65 -0
- package/src/ui/styled/PwaRegistration.tsx +58 -0
- package/src/ui/styled/ReactionBar.tsx +93 -0
- package/src/ui/styled/ReplayList.tsx +134 -0
- package/src/ui/styled/ResetPasswordForm.tsx +127 -0
- package/src/ui/styled/ReviewForm.tsx +292 -0
- package/src/ui/styled/ReviewsSection.tsx +260 -0
- package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
- package/src/ui/styled/SignupForm.tsx +109 -0
- package/src/ui/styled/UserMenu.tsx +166 -0
- package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
- package/src/ui/styled/chat/ChatComposer.tsx +153 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
- package/src/ui/styled/chat/MessageBubble.tsx +66 -0
- package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
- package/src/ui/styled/community/CommunityComposer.tsx +358 -0
- package/src/ui/styled/community/CommunityFeed.tsx +483 -0
- package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
- package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
- package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
- package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
- package/src/ui/styled/community/CommunityRichText.tsx +157 -0
- package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
- package/src/ui/styled/community/MemberAvatar.tsx +74 -0
- package/src/ui/styled/community/index.ts +23 -0
- package/src/ui/styled/community/util.ts +15 -0
- package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
- package/src/ui/styled/members/MemberPostCard.tsx +392 -0
- package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
- package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
- package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
- package/src/ui/styled/members/index.ts +7 -0
- package/src/ui/styled/rich-text.css +150 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
- package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
- package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
- package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
- package/src/ui/styled/work/WorkReportBody.tsx +327 -0
- package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
- package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
- package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
- package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
- package/src/ui/styled/work/index.ts +10 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
- package/src/ui/theme/contrast.ts +22 -0
- package/src/utils/analyticsBus.ts +31 -0
- package/src/utils/attribution.ts +68 -0
- package/src/utils/cookieConsent.ts +83 -0
- package/src/utils/landing.ts +153 -0
- package/src/utils/metaPixel.ts +122 -0
- package/src/utils/structuredData.ts +126 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useEffect, useMemo } from "react";
|
|
2
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
4
|
+
import {
|
|
5
|
+
applyReactionDelta,
|
|
6
|
+
bumpFeedReplyCount,
|
|
7
|
+
prependPostToFeed,
|
|
8
|
+
useCommunitySocket,
|
|
9
|
+
useCommunitySpacePosts,
|
|
10
|
+
useCommunitySpaces,
|
|
11
|
+
useCreateCommunityPost,
|
|
12
|
+
useJoinCommunitySpace,
|
|
13
|
+
useLeaveCommunitySpace,
|
|
14
|
+
useToggleCommunityReaction,
|
|
15
|
+
type CommunityPost,
|
|
16
|
+
type CommunityReactionEvent,
|
|
17
|
+
type CommunityReply,
|
|
18
|
+
} from "../../../data/queries/useCommunity";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Headless space feed: the space (from the gated spaces list), its paginated
|
|
22
|
+
* posts, realtime new-post/reaction updates over the shared Socket.io gateway
|
|
23
|
+
* (room `community:<spaceId>`, joined via `communityJoinSpace` exactly like
|
|
24
|
+
* chat joins channels), plus create-post and toggle-reaction actions.
|
|
25
|
+
*
|
|
26
|
+
* The single primitive a creator needs to rebuild a space feed page.
|
|
27
|
+
*
|
|
28
|
+
* `idOrSlug` accepts either a space uuid or its per-profile slug — the space is
|
|
29
|
+
* resolved from the gated spaces list by id OR slug, so slug-addressed deep
|
|
30
|
+
* links load the right space without a separate lookup.
|
|
31
|
+
*/
|
|
32
|
+
export function useCommunitySpaceFeed(idOrSlug?: string) {
|
|
33
|
+
const { user } = usePublicAuth();
|
|
34
|
+
const queryClient = useQueryClient();
|
|
35
|
+
|
|
36
|
+
const { data: spaces = [], isLoading: spacesLoading } = useCommunitySpaces();
|
|
37
|
+
const space = useMemo(
|
|
38
|
+
() => spaces.find((s) => s.id === idOrSlug || s.slug === idOrSlug) ?? null,
|
|
39
|
+
[spaces, idOrSlug],
|
|
40
|
+
);
|
|
41
|
+
// Downstream hooks (posts, realtime room, create) are keyed by the canonical
|
|
42
|
+
// space uuid — only wire them up once the space resolves from the list, so a
|
|
43
|
+
// slug-addressed link never leaks a slug into a uuid-keyed request/room.
|
|
44
|
+
const spaceId = space?.id;
|
|
45
|
+
|
|
46
|
+
const {
|
|
47
|
+
data: postPages,
|
|
48
|
+
isLoading: postsLoading,
|
|
49
|
+
fetchNextPage,
|
|
50
|
+
hasNextPage,
|
|
51
|
+
isFetchingNextPage,
|
|
52
|
+
} = useCommunitySpacePosts(spaceId);
|
|
53
|
+
const posts = useMemo(() => postPages?.pages.flatMap((p) => p.data) ?? [], [postPages]);
|
|
54
|
+
|
|
55
|
+
const createPost = useCreateCommunityPost(spaceId);
|
|
56
|
+
const toggleReaction = useToggleCommunityReaction();
|
|
57
|
+
const joinSpace = useJoinCommunitySpace();
|
|
58
|
+
const leaveSpace = useLeaveCommunitySpace();
|
|
59
|
+
|
|
60
|
+
// ── Realtime ───────────────────────────────────────────────────────────────
|
|
61
|
+
const socket = useCommunitySocket(!!user);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!socket || !spaceId) return;
|
|
65
|
+
const onMessage = (data: {
|
|
66
|
+
event?: string;
|
|
67
|
+
data?: {
|
|
68
|
+
spaceId?: string;
|
|
69
|
+
postId?: string;
|
|
70
|
+
post?: CommunityPost;
|
|
71
|
+
reply?: CommunityReply;
|
|
72
|
+
} & Partial<CommunityReactionEvent>;
|
|
73
|
+
}) => {
|
|
74
|
+
if (data.event === "communityNewPost" && data.data?.spaceId === spaceId && data.data?.post) {
|
|
75
|
+
prependPostToFeed(queryClient, spaceId, data.data.post);
|
|
76
|
+
}
|
|
77
|
+
if (data.event === "communityNewReply" && data.data?.spaceId === spaceId && data.data?.postId) {
|
|
78
|
+
bumpFeedReplyCount(queryClient, spaceId, data.data.postId);
|
|
79
|
+
}
|
|
80
|
+
if (data.event === "communityReaction" && data.data?.entityId && data.data?.accountId !== user?.id) {
|
|
81
|
+
applyReactionDelta(queryClient, data.data as CommunityReactionEvent);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
socket.on("message", onMessage);
|
|
85
|
+
return () => {
|
|
86
|
+
socket.off("message", onMessage);
|
|
87
|
+
};
|
|
88
|
+
}, [socket, spaceId, queryClient, user?.id]);
|
|
89
|
+
|
|
90
|
+
// Join/leave the space room (mirrors chatJoinChannel).
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!spaceId || !socket) return;
|
|
93
|
+
const join = () => socket.emit("communityJoinSpace", { spaceId });
|
|
94
|
+
if (socket.connected) join();
|
|
95
|
+
socket.on("connect", join);
|
|
96
|
+
return () => {
|
|
97
|
+
socket.off("connect", join);
|
|
98
|
+
socket.emit("communityLeaveSpace", { spaceId });
|
|
99
|
+
};
|
|
100
|
+
}, [spaceId, socket]);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
space,
|
|
104
|
+
spaces,
|
|
105
|
+
spacesLoading,
|
|
106
|
+
posts,
|
|
107
|
+
postsLoading,
|
|
108
|
+
fetchNextPage,
|
|
109
|
+
hasMore: !!hasNextPage && !isFetchingNextPage,
|
|
110
|
+
currentAccountId: user?.id,
|
|
111
|
+
// Belonging — tiers govern access, join governs new-post notifications.
|
|
112
|
+
isJoined: space?.isJoined ?? false,
|
|
113
|
+
memberCount: space?.memberCount ?? null,
|
|
114
|
+
joinPending: joinSpace.isPending || leaveSpace.isPending,
|
|
115
|
+
// Actions
|
|
116
|
+
createPost,
|
|
117
|
+
join: () => spaceId && joinSpace.mutate(spaceId),
|
|
118
|
+
leave: () => spaceId && leaveSpace.mutate(spaceId),
|
|
119
|
+
react: (entityId: string, emoji: string) =>
|
|
120
|
+
toggleReaction.mutate({ entityType: "community_post", entityId, emoji, spaceId }),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useEffect, useMemo } from "react";
|
|
2
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
4
|
+
import {
|
|
5
|
+
appendReplyToThread,
|
|
6
|
+
applyReactionDelta,
|
|
7
|
+
useCommunityPost,
|
|
8
|
+
useCommunityReplies,
|
|
9
|
+
useCommunitySocket,
|
|
10
|
+
useCreateCommunityReply,
|
|
11
|
+
useToggleCommunityReaction,
|
|
12
|
+
type CommunityReactionEvent,
|
|
13
|
+
type CommunityReply,
|
|
14
|
+
type ReactableEntityType,
|
|
15
|
+
} from "../../../data/queries/useCommunity";
|
|
16
|
+
import { buildReplyTree } from "./replyTree";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Headless post thread: the post, its full reply tree (assembled from the flat
|
|
20
|
+
* pages — data nesting is unlimited, display capping is the renderer's job),
|
|
21
|
+
* realtime reply/reaction updates via the post's space room, plus reply and
|
|
22
|
+
* toggle-reaction actions.
|
|
23
|
+
*/
|
|
24
|
+
export function useCommunityThread(postId?: string) {
|
|
25
|
+
const { user } = usePublicAuth();
|
|
26
|
+
const queryClient = useQueryClient();
|
|
27
|
+
|
|
28
|
+
const { data: post, isLoading: postLoading, error: postError } = useCommunityPost(postId);
|
|
29
|
+
const {
|
|
30
|
+
data: replyPages,
|
|
31
|
+
isLoading: repliesLoading,
|
|
32
|
+
fetchNextPage,
|
|
33
|
+
hasNextPage,
|
|
34
|
+
isFetchingNextPage,
|
|
35
|
+
} = useCommunityReplies(postId);
|
|
36
|
+
|
|
37
|
+
const replies = useMemo(() => replyPages?.pages.flatMap((p) => p.data) ?? [], [replyPages]);
|
|
38
|
+
const replyTree = useMemo(() => buildReplyTree(replies), [replies]);
|
|
39
|
+
|
|
40
|
+
const createReply = useCreateCommunityReply(postId);
|
|
41
|
+
const toggleReaction = useToggleCommunityReaction();
|
|
42
|
+
|
|
43
|
+
const spaceId = post?.spaceId;
|
|
44
|
+
|
|
45
|
+
// ── Realtime (same space room the feed uses) ──────────────────────────────
|
|
46
|
+
const socket = useCommunitySocket(!!user);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!socket || !postId) return;
|
|
50
|
+
const onMessage = (data: {
|
|
51
|
+
event?: string;
|
|
52
|
+
data?: { spaceId?: string; postId?: string; reply?: CommunityReply } & Partial<CommunityReactionEvent>;
|
|
53
|
+
}) => {
|
|
54
|
+
if (data.event === "communityNewReply" && data.data?.postId === postId && data.data?.reply) {
|
|
55
|
+
// Skip own replies — the mutation already appended them.
|
|
56
|
+
if (data.data.reply.authorAccountId === user?.id) return;
|
|
57
|
+
appendReplyToThread(queryClient, postId, data.data.reply);
|
|
58
|
+
}
|
|
59
|
+
if (data.event === "communityReaction" && data.data?.entityId && data.data?.accountId !== user?.id) {
|
|
60
|
+
applyReactionDelta(queryClient, data.data as CommunityReactionEvent, { postId });
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
socket.on("message", onMessage);
|
|
64
|
+
return () => {
|
|
65
|
+
socket.off("message", onMessage);
|
|
66
|
+
};
|
|
67
|
+
}, [socket, postId, queryClient, user?.id]);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!spaceId || !socket) return;
|
|
71
|
+
const join = () => socket.emit("communityJoinSpace", { spaceId });
|
|
72
|
+
if (socket.connected) join();
|
|
73
|
+
socket.on("connect", join);
|
|
74
|
+
return () => {
|
|
75
|
+
socket.off("connect", join);
|
|
76
|
+
socket.emit("communityLeaveSpace", { spaceId });
|
|
77
|
+
};
|
|
78
|
+
}, [spaceId, socket]);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
post,
|
|
82
|
+
postLoading,
|
|
83
|
+
postError,
|
|
84
|
+
replies,
|
|
85
|
+
replyTree,
|
|
86
|
+
repliesLoading,
|
|
87
|
+
fetchNextPage,
|
|
88
|
+
hasMore: !!hasNextPage && !isFetchingNextPage,
|
|
89
|
+
currentAccountId: user?.id,
|
|
90
|
+
// Actions
|
|
91
|
+
createReply,
|
|
92
|
+
react: (entityType: Extract<ReactableEntityType, "community_post" | "community_reply">, entityId: string, emoji: string) =>
|
|
93
|
+
toggleReaction.mutate({ entityType, entityId, emoji, spaceId, postId }),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
communityMemberName,
|
|
4
|
+
useCommunityMentionDirectory,
|
|
5
|
+
type CommunityLeaderboardEntry,
|
|
6
|
+
} from "../../../data/queries/useCommunity";
|
|
7
|
+
import { buildLexicalState, mentionsInText, type MentionRef } from "./lexical";
|
|
8
|
+
|
|
9
|
+
export interface MentionSuggestion {
|
|
10
|
+
accountId: string;
|
|
11
|
+
name: string;
|
|
12
|
+
avatarUrl: string | null;
|
|
13
|
+
level: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Headless @mention textarea state machine. Track the draft + caret; when the
|
|
18
|
+
* text before the caret ends in an `@query` token, `suggestions` lights up with
|
|
19
|
+
* matching members (from the community member directory). `insertMention`
|
|
20
|
+
* replaces the token and records the ref so `getBody()` can emit Lexical
|
|
21
|
+
* mention nodes + explicit mentionedAccountIds.
|
|
22
|
+
*/
|
|
23
|
+
export function useMentionComposer(initialText = "") {
|
|
24
|
+
const [text, setText] = useState(initialText);
|
|
25
|
+
const [caret, setCaret] = useState(initialText.length);
|
|
26
|
+
const [trackedMentions, setTrackedMentions] = useState<MentionRef[]>([]);
|
|
27
|
+
const { data: directory = [] } = useCommunityMentionDirectory();
|
|
28
|
+
|
|
29
|
+
const members: MentionSuggestion[] = useMemo(
|
|
30
|
+
() =>
|
|
31
|
+
directory.map((m: CommunityLeaderboardEntry) => ({
|
|
32
|
+
accountId: m.accountId,
|
|
33
|
+
name: communityMemberName(m),
|
|
34
|
+
avatarUrl: m.avatarUrl,
|
|
35
|
+
level: m.level,
|
|
36
|
+
})),
|
|
37
|
+
[directory],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/** The active "@query" token immediately before the caret, if any. */
|
|
41
|
+
const activeToken = useMemo(() => {
|
|
42
|
+
const upToCaret = text.slice(0, caret);
|
|
43
|
+
const match = /(^|\s)@([^\s@]{0,40})$/.exec(upToCaret);
|
|
44
|
+
if (!match) return null;
|
|
45
|
+
return { query: match[2], start: caret - match[2].length - 1 };
|
|
46
|
+
}, [text, caret]);
|
|
47
|
+
|
|
48
|
+
const suggestions: MentionSuggestion[] = useMemo(() => {
|
|
49
|
+
if (!activeToken) return [];
|
|
50
|
+
const q = activeToken.query.toLowerCase();
|
|
51
|
+
return members
|
|
52
|
+
.filter((m) => m.name.toLowerCase().includes(q))
|
|
53
|
+
.slice(0, 6);
|
|
54
|
+
}, [activeToken, members]);
|
|
55
|
+
|
|
56
|
+
const onTextChange = useCallback((nextText: string, nextCaret: number) => {
|
|
57
|
+
setText(nextText);
|
|
58
|
+
setCaret(nextCaret);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const insertMention = useCallback(
|
|
62
|
+
(member: MentionSuggestion) => {
|
|
63
|
+
if (!activeToken) return;
|
|
64
|
+
const before = text.slice(0, activeToken.start);
|
|
65
|
+
const after = text.slice(caret);
|
|
66
|
+
const inserted = `@${member.name} `;
|
|
67
|
+
const nextText = before + inserted + after;
|
|
68
|
+
setText(nextText);
|
|
69
|
+
setCaret(before.length + inserted.length);
|
|
70
|
+
setTrackedMentions((prev) =>
|
|
71
|
+
prev.some((m) => m.accountId === member.accountId)
|
|
72
|
+
? prev
|
|
73
|
+
: [...prev, { accountId: member.accountId, name: member.name }],
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
[activeToken, text, caret],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
/** Mentions that still literally appear in the draft (survive edits/deletes). */
|
|
80
|
+
const liveMentions = useMemo(() => mentionsInText(text, trackedMentions), [text, trackedMentions]);
|
|
81
|
+
|
|
82
|
+
/** Lexical editor state for the current draft, mention nodes included. */
|
|
83
|
+
const getBody = useCallback(() => buildLexicalState(text, liveMentions), [text, liveMentions]);
|
|
84
|
+
|
|
85
|
+
const reset = useCallback(() => {
|
|
86
|
+
setText("");
|
|
87
|
+
setCaret(0);
|
|
88
|
+
setTrackedMentions([]);
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
text,
|
|
93
|
+
onTextChange,
|
|
94
|
+
setCaret,
|
|
95
|
+
isEmpty: !text.trim(),
|
|
96
|
+
// Autocomplete
|
|
97
|
+
suggestions,
|
|
98
|
+
isSuggesting: !!activeToken && suggestions.length > 0,
|
|
99
|
+
insertMention,
|
|
100
|
+
// Submission
|
|
101
|
+
getBody,
|
|
102
|
+
mentionedAccountIds: liveMentions.map((m) => m.accountId),
|
|
103
|
+
reset,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
communityMemberName,
|
|
4
|
+
useCommunityMentionDirectory,
|
|
5
|
+
type CommunityLeaderboardEntry,
|
|
6
|
+
} from "../../../data/queries/useCommunity";
|
|
7
|
+
import type { MentionSuggestion } from "./useMentionComposer";
|
|
8
|
+
import { collectMentions, isRichEmpty, serializeRichToLexical } from "./richLexical";
|
|
9
|
+
|
|
10
|
+
export type { MentionSuggestion } from "./useMentionComposer";
|
|
11
|
+
|
|
12
|
+
export type RichCommand = "bold" | "italic" | "underline" | "insertUnorderedList" | "insertOrderedList";
|
|
13
|
+
|
|
14
|
+
/** The active "@query" token immediately before the caret, if any. */
|
|
15
|
+
interface ActiveToken {
|
|
16
|
+
query: string;
|
|
17
|
+
/** The text node holding the token, and the char offset where `@` starts. */
|
|
18
|
+
textNode: Text;
|
|
19
|
+
atOffset: number;
|
|
20
|
+
caretOffset: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Headless rich-text + @mention composer over a contentEditable div. The DOM is
|
|
25
|
+
* the source of truth: formatting goes through `document.execCommand`, mentions
|
|
26
|
+
* are inserted as non-editable chip spans, and `getBody()` serializes the live
|
|
27
|
+
* DOM to the Lexical JSON CommunityRichText renders. Replaces the plain-text
|
|
28
|
+
* `useMentionComposer` for the community composer while keeping its onSubmit
|
|
29
|
+
* contract (body / mentionedAccountIds).
|
|
30
|
+
*/
|
|
31
|
+
export function useRichMentionComposer() {
|
|
32
|
+
const editorRef = useRef<HTMLDivElement | null>(null);
|
|
33
|
+
const [activeToken, setActiveToken] = useState<ActiveToken | null>(null);
|
|
34
|
+
// Bumps whenever DOM content changes so `isEmpty` recomputes.
|
|
35
|
+
const [tick, setTick] = useState(0);
|
|
36
|
+
const bump = useCallback(() => setTick((n) => n + 1), []);
|
|
37
|
+
|
|
38
|
+
const { data: directory = [] } = useCommunityMentionDirectory();
|
|
39
|
+
|
|
40
|
+
const members: MentionSuggestion[] = useMemo(
|
|
41
|
+
() =>
|
|
42
|
+
directory.map((m: CommunityLeaderboardEntry) => ({
|
|
43
|
+
accountId: m.accountId,
|
|
44
|
+
name: communityMemberName(m),
|
|
45
|
+
avatarUrl: m.avatarUrl,
|
|
46
|
+
level: m.level,
|
|
47
|
+
})),
|
|
48
|
+
[directory],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const suggestions: MentionSuggestion[] = useMemo(() => {
|
|
52
|
+
if (!activeToken) return [];
|
|
53
|
+
const q = activeToken.query.toLowerCase();
|
|
54
|
+
return members.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 6);
|
|
55
|
+
}, [activeToken, members]);
|
|
56
|
+
|
|
57
|
+
/** Recompute the active @query token from the current selection. */
|
|
58
|
+
const detectToken = useCallback(() => {
|
|
59
|
+
const editor = editorRef.current;
|
|
60
|
+
if (!editor) return setActiveToken(null);
|
|
61
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
62
|
+
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return setActiveToken(null);
|
|
63
|
+
|
|
64
|
+
const range = sel.getRangeAt(0);
|
|
65
|
+
const node = range.startContainer;
|
|
66
|
+
// Only text nodes inside the editor carry a typeable @query.
|
|
67
|
+
if (node.nodeType !== 3 || !editor.contains(node)) return setActiveToken(null);
|
|
68
|
+
const textNode = node as Text;
|
|
69
|
+
const caretOffset = range.startOffset;
|
|
70
|
+
const upToCaret = (textNode.textContent ?? "").slice(0, caretOffset);
|
|
71
|
+
const match = /(^|\s)@([^\s@]{0,40})$/.exec(upToCaret);
|
|
72
|
+
if (!match) return setActiveToken(null);
|
|
73
|
+
const query = match[2];
|
|
74
|
+
setActiveToken({ query, textNode, atOffset: caretOffset - query.length - 1, caretOffset });
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
/** Fired on input/keyup/click/select — refresh emptiness + token. */
|
|
78
|
+
const onEditorChange = useCallback(() => {
|
|
79
|
+
bump();
|
|
80
|
+
detectToken();
|
|
81
|
+
}, [bump, detectToken]);
|
|
82
|
+
|
|
83
|
+
/** Run a formatting command against the current selection. */
|
|
84
|
+
const format = useCallback(
|
|
85
|
+
(command: RichCommand) => {
|
|
86
|
+
const editor = editorRef.current;
|
|
87
|
+
if (!editor) return;
|
|
88
|
+
editor.focus();
|
|
89
|
+
document.execCommand(command);
|
|
90
|
+
onEditorChange();
|
|
91
|
+
},
|
|
92
|
+
[onEditorChange],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
/** Prompt for a URL and wrap the current selection in a link. */
|
|
96
|
+
const insertLink = useCallback(
|
|
97
|
+
(url?: string) => {
|
|
98
|
+
const editor = editorRef.current;
|
|
99
|
+
if (!editor) return;
|
|
100
|
+
editor.focus();
|
|
101
|
+
const href = url ?? (typeof window !== "undefined" ? window.prompt("Link URL") ?? "" : "");
|
|
102
|
+
if (!href.trim()) return;
|
|
103
|
+
const normalized = /^https?:\/\//i.test(href) || href.startsWith("/") ? href : `https://${href}`;
|
|
104
|
+
document.execCommand("createLink", false, normalized);
|
|
105
|
+
onEditorChange();
|
|
106
|
+
},
|
|
107
|
+
[onEditorChange],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
/** Replace the active @token with a non-editable mention chip. */
|
|
111
|
+
const insertMention = useCallback(
|
|
112
|
+
(member: MentionSuggestion) => {
|
|
113
|
+
const editor = editorRef.current;
|
|
114
|
+
const token = activeToken;
|
|
115
|
+
if (!editor || !token) return;
|
|
116
|
+
const doc = editor.ownerDocument;
|
|
117
|
+
const sel = doc.defaultView?.getSelection();
|
|
118
|
+
if (!sel) return;
|
|
119
|
+
|
|
120
|
+
// Select the "@query" span and replace it with a chip + trailing space.
|
|
121
|
+
const range = doc.createRange();
|
|
122
|
+
range.setStart(token.textNode, token.atOffset);
|
|
123
|
+
range.setEnd(token.textNode, token.caretOffset);
|
|
124
|
+
range.deleteContents();
|
|
125
|
+
|
|
126
|
+
const chip = doc.createElement("span");
|
|
127
|
+
chip.setAttribute("data-account-id", member.accountId);
|
|
128
|
+
chip.setAttribute("data-mention-name", member.name);
|
|
129
|
+
chip.setAttribute("contenteditable", "false");
|
|
130
|
+
chip.textContent = `@${member.name}`;
|
|
131
|
+
|
|
132
|
+
const trailing = doc.createTextNode(" ");
|
|
133
|
+
range.insertNode(trailing);
|
|
134
|
+
range.insertNode(chip);
|
|
135
|
+
|
|
136
|
+
// Move the caret after the trailing space.
|
|
137
|
+
const after = doc.createRange();
|
|
138
|
+
after.setStartAfter(trailing);
|
|
139
|
+
after.collapse(true);
|
|
140
|
+
sel.removeAllRanges();
|
|
141
|
+
sel.addRange(after);
|
|
142
|
+
|
|
143
|
+
setActiveToken(null);
|
|
144
|
+
bump();
|
|
145
|
+
},
|
|
146
|
+
[activeToken, bump],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const getBody = useCallback(() => {
|
|
150
|
+
const editor = editorRef.current;
|
|
151
|
+
if (!editor) return serializeRichToLexical(document.createElement("div"));
|
|
152
|
+
return serializeRichToLexical(editor);
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
const getMentionedAccountIds = useCallback(() => {
|
|
156
|
+
const editor = editorRef.current;
|
|
157
|
+
if (!editor) return [];
|
|
158
|
+
return collectMentions(editor).map((m) => m.accountId);
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
const isEmpty = useMemo(() => {
|
|
162
|
+
// `tick` is the recompute trigger: it bumps on every DOM mutation.
|
|
163
|
+
void tick;
|
|
164
|
+
const editor = editorRef.current;
|
|
165
|
+
if (!editor) return true;
|
|
166
|
+
return isRichEmpty(editor);
|
|
167
|
+
}, [tick]);
|
|
168
|
+
|
|
169
|
+
const reset = useCallback(() => {
|
|
170
|
+
const editor = editorRef.current;
|
|
171
|
+
if (editor) editor.innerHTML = "";
|
|
172
|
+
setActiveToken(null);
|
|
173
|
+
bump();
|
|
174
|
+
}, [bump]);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
editorRef,
|
|
178
|
+
onEditorChange,
|
|
179
|
+
// Formatting
|
|
180
|
+
format,
|
|
181
|
+
insertLink,
|
|
182
|
+
// Autocomplete
|
|
183
|
+
suggestions,
|
|
184
|
+
isSuggesting: !!activeToken && suggestions.length > 0,
|
|
185
|
+
insertMention,
|
|
186
|
+
dismissSuggestions: () => setActiveToken(null),
|
|
187
|
+
// Submission / state
|
|
188
|
+
isEmpty,
|
|
189
|
+
getBody,
|
|
190
|
+
getMentionedAccountIds,
|
|
191
|
+
reset,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
type CookieConsent,
|
|
4
|
+
getCookieConsent,
|
|
5
|
+
saveCookieConsent,
|
|
6
|
+
clearCookieConsent,
|
|
7
|
+
onCookieConsentChange,
|
|
8
|
+
DENY_ALL,
|
|
9
|
+
GRANT_ALL,
|
|
10
|
+
} from "../../../utils/cookieConsent";
|
|
11
|
+
|
|
12
|
+
export interface UseCookieConsentResult {
|
|
13
|
+
/** The current decision, or `null` if the visitor hasn't chosen yet. */
|
|
14
|
+
consent: CookieConsent | null;
|
|
15
|
+
/** False until the stored choice has been read from storage after mount. Gate
|
|
16
|
+
* an auto-showing banner on this so it doesn't flash for a returning visitor
|
|
17
|
+
* who already decided (whose decision only loads post-mount). */
|
|
18
|
+
ready: boolean;
|
|
19
|
+
/** True once a decision exists (used to show/hide a banner). */
|
|
20
|
+
decided: boolean;
|
|
21
|
+
/** Convenience booleans (false until decided). */
|
|
22
|
+
performance: boolean;
|
|
23
|
+
marketing: boolean;
|
|
24
|
+
/** Grant every category. */
|
|
25
|
+
acceptAll: () => void;
|
|
26
|
+
/** Reject everything but strictly-necessary. */
|
|
27
|
+
rejectAll: () => void;
|
|
28
|
+
/** Persist a specific set of choices (functional is forced on). */
|
|
29
|
+
save: (consent: Partial<CookieConsent>) => void;
|
|
30
|
+
/** Wipe the decision so the banner shows again (e.g. a "Cookie settings" link). */
|
|
31
|
+
reopen: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Headless cookie-consent state. Reads the shared consent store and stays in
|
|
36
|
+
* sync across components and tabs, so a banner and the analytics layer agree
|
|
37
|
+
* without prop-drilling. This is the primitive behind `<CookieConsent />` and
|
|
38
|
+
* the gate `<ForgeAnalytics />` uses — a creator can build their own banner on
|
|
39
|
+
* top of it.
|
|
40
|
+
*/
|
|
41
|
+
export function useCookieConsent(): UseCookieConsentResult {
|
|
42
|
+
// Always start `null` so the first client render matches the server (which has
|
|
43
|
+
// no `localStorage`). Reading storage during render would desync SSR ↔ client
|
|
44
|
+
// and abort hydration — leaving the banner visible but with no event handlers
|
|
45
|
+
// (dead toggles/buttons). Populate from storage after mount instead.
|
|
46
|
+
const [consent, setConsent] = useState<CookieConsent | null>(null);
|
|
47
|
+
const [ready, setReady] = useState(false);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
setConsent(getCookieConsent());
|
|
51
|
+
setReady(true);
|
|
52
|
+
return onCookieConsentChange(() => setConsent(getCookieConsent()));
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
const acceptAll = useCallback(() => saveCookieConsent(GRANT_ALL), []);
|
|
56
|
+
const rejectAll = useCallback(() => saveCookieConsent(DENY_ALL), []);
|
|
57
|
+
const save = useCallback(
|
|
58
|
+
(next: Partial<CookieConsent>) =>
|
|
59
|
+
saveCookieConsent({
|
|
60
|
+
functional: true,
|
|
61
|
+
performance: !!next.performance,
|
|
62
|
+
marketing: !!next.marketing,
|
|
63
|
+
}),
|
|
64
|
+
[],
|
|
65
|
+
);
|
|
66
|
+
// Broadcast so a `<CookieConsent/>` rendered elsewhere (e.g. TribeNestApp) re-opens
|
|
67
|
+
// — the event handler above re-reads the (now-empty) store for every instance.
|
|
68
|
+
const reopen = useCallback(() => clearCookieConsent(), []);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
consent,
|
|
72
|
+
ready,
|
|
73
|
+
decided: consent !== null,
|
|
74
|
+
performance: consent?.performance ?? false,
|
|
75
|
+
marketing: consent?.marketing ?? false,
|
|
76
|
+
acceptAll,
|
|
77
|
+
rejectAll,
|
|
78
|
+
save,
|
|
79
|
+
reopen,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
3
|
+
import { useGetCourse, useCreateCourseBooking } from "../../../data/queries/useCourses";
|
|
4
|
+
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
5
|
+
import { readAttributionRef } from "../../../utils/attribution";
|
|
6
|
+
import { readLanding } from "../../../utils/landing";
|
|
7
|
+
|
|
8
|
+
export type CourseCheckoutStep = "details" | "payment";
|
|
9
|
+
|
|
10
|
+
export interface UseCourseCheckoutOptions {
|
|
11
|
+
/** Stripe return URL for a PAID purchase (its `return_url`). Default
|
|
12
|
+
* `/i/courses/:slug/finalise`. A real URL is required for the redirect leg. */
|
|
13
|
+
finalisePath?: (slug: string, bookingId: string) => string;
|
|
14
|
+
/** Called when a FREE enrollment completes in-app — the host navigates however
|
|
15
|
+
* it wants, instead of Forge doing a `window.location` redirect. */
|
|
16
|
+
onComplete?: (info: { slug: string; bookingId: string }) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const errMessage = (e: unknown) =>
|
|
20
|
+
(e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Something went wrong.";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Headless course purchase: buyer details → payment. Composes `useGetCourse`,
|
|
24
|
+
* `useCreateCourseBooking`, `usePaymentFlow`. Free courses skip payment and
|
|
25
|
+
* redirect to the finalise page. Finalize via `useCourseBookingFinalize`.
|
|
26
|
+
*/
|
|
27
|
+
export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions = {}) {
|
|
28
|
+
const { user } = usePublicAuth();
|
|
29
|
+
// Detail by slug; the booking is created against the real course id.
|
|
30
|
+
const { data: course, isLoading } = useGetCourse(slug);
|
|
31
|
+
const courseId = course?.id;
|
|
32
|
+
const createBooking = useCreateCourseBooking(courseId);
|
|
33
|
+
const flow = usePaymentFlow({ path: `/public/courses/${courseId}/start-payment`, autoStart: false });
|
|
34
|
+
|
|
35
|
+
const [step, setStep] = useState<CourseCheckoutStep>("details");
|
|
36
|
+
const [firstName, setFirstName] = useState(user?.firstName ?? "");
|
|
37
|
+
const [lastName, setLastName] = useState(user?.lastName ?? "");
|
|
38
|
+
const [email, setEmail] = useState(user?.email ?? "");
|
|
39
|
+
const [questionnaire, setQuestionnaire] = useState<unknown>(undefined);
|
|
40
|
+
const [returnUrl, setReturnUrl] = useState("");
|
|
41
|
+
const [error, setError] = useState<string | null>(null);
|
|
42
|
+
|
|
43
|
+
const finalisePath =
|
|
44
|
+
opts.finalisePath ?? ((s, bookingId) => `/i/courses/${s}/finalise?bookingId=${bookingId}`);
|
|
45
|
+
|
|
46
|
+
const continueToPayment = async () => {
|
|
47
|
+
setError(null);
|
|
48
|
+
if (!slug || !courseId) return;
|
|
49
|
+
if (!firstName.trim() || !lastName.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
50
|
+
setError("Please enter your name and a valid email.");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const data = await createBooking.mutateAsync({
|
|
55
|
+
email,
|
|
56
|
+
firstName,
|
|
57
|
+
lastName,
|
|
58
|
+
questionnaire,
|
|
59
|
+
attributionRefId: readAttributionRef() ?? undefined,
|
|
60
|
+
...(readLanding() ?? {}),
|
|
61
|
+
});
|
|
62
|
+
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
63
|
+
const ru = `${origin}${finalisePath(slug, data.bookingId)}`;
|
|
64
|
+
setReturnUrl(ru);
|
|
65
|
+
if (data.isFree) {
|
|
66
|
+
if (opts.onComplete) opts.onComplete({ slug, bookingId: data.bookingId });
|
|
67
|
+
else if (typeof window !== "undefined") window.location.href = ru;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const result = await flow.start({ bookingId: data.bookingId, returnUrl: ru });
|
|
71
|
+
if (result.provider && result.provider !== "stripe") return;
|
|
72
|
+
setStep("payment");
|
|
73
|
+
} catch (e) {
|
|
74
|
+
setError(errMessage(e));
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
course,
|
|
80
|
+
isLoading,
|
|
81
|
+
step,
|
|
82
|
+
setStep,
|
|
83
|
+
firstName,
|
|
84
|
+
setFirstName,
|
|
85
|
+
lastName,
|
|
86
|
+
setLastName,
|
|
87
|
+
email,
|
|
88
|
+
setEmail,
|
|
89
|
+
questionnaire,
|
|
90
|
+
setQuestionnaire,
|
|
91
|
+
continueToPayment,
|
|
92
|
+
clientSecret: flow.clientSecret,
|
|
93
|
+
/** Authoritative sales-tax quote from start-payment (display only). */
|
|
94
|
+
taxQuote: flow.result?.taxQuote ?? null,
|
|
95
|
+
returnUrl,
|
|
96
|
+
isProcessing: createBooking.isPending || flow.isStarting,
|
|
97
|
+
error,
|
|
98
|
+
};
|
|
99
|
+
}
|