@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,256 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { usePublicForm, useSubmitPublicForm } from "../../../data/queries/useForms";
|
|
3
|
+
import type { FormData, FormField, FormFieldEdge } from "../../../types/models";
|
|
4
|
+
|
|
5
|
+
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6
|
+
|
|
7
|
+
// --- Seeded per-attempt option shuffle (quiz mode) ---------------------------
|
|
8
|
+
// Stable for the lifetime of the mount so the review screen matches the answered
|
|
9
|
+
// order. Mirrors the flat renderer's shuffle helper.
|
|
10
|
+
function hashSeed(str: string): number {
|
|
11
|
+
let h = 1779033703 ^ str.length;
|
|
12
|
+
for (let i = 0; i < str.length; i++) {
|
|
13
|
+
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
|
|
14
|
+
h = (h << 13) | (h >>> 19);
|
|
15
|
+
}
|
|
16
|
+
h = Math.imul(h ^ (h >>> 16), 2246822507);
|
|
17
|
+
h = Math.imul(h ^ (h >>> 13), 3266489909);
|
|
18
|
+
return (h ^= h >>> 16) >>> 0;
|
|
19
|
+
}
|
|
20
|
+
function mulberry32(seed: number): () => number {
|
|
21
|
+
let a = seed >>> 0;
|
|
22
|
+
return () => {
|
|
23
|
+
a |= 0;
|
|
24
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
25
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
26
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
27
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function seededShuffle<T>(items: T[], rand: () => number): T[] {
|
|
31
|
+
const out = items.slice();
|
|
32
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
33
|
+
const j = Math.floor(rand() * (i + 1));
|
|
34
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
function shuffleFormOptions(form: FormData | undefined, seed: string): FormData | undefined {
|
|
39
|
+
if (!form) return form;
|
|
40
|
+
const settings = typeof form.settings === "string" ? safeParseSettings(form.settings) : form.settings || {};
|
|
41
|
+
if (!settings?.quiz?.enabled || !settings?.quiz?.shuffleOptions) return form;
|
|
42
|
+
const fields = form.fields.map((f) => {
|
|
43
|
+
if (!f.options || f.options.length < 2) return f;
|
|
44
|
+
return { ...f, options: seededShuffle(f.options, mulberry32(hashSeed(`${seed}:${f.id}`))) };
|
|
45
|
+
});
|
|
46
|
+
return { ...form, fields };
|
|
47
|
+
}
|
|
48
|
+
function safeParseSettings(s: string): any {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(s);
|
|
51
|
+
} catch {
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type Condition = { operator: string; value: string | string[] | null } | null;
|
|
57
|
+
|
|
58
|
+
function evaluateCondition(raw: unknown, answer: string | undefined | null): boolean {
|
|
59
|
+
const condition = (typeof raw === "string" ? JSON.parse(raw) : raw) as Condition;
|
|
60
|
+
if (!condition || !condition.operator) return false;
|
|
61
|
+
const { operator, value } = condition;
|
|
62
|
+
switch (operator) {
|
|
63
|
+
case "equals":
|
|
64
|
+
return answer === value;
|
|
65
|
+
case "not_equals":
|
|
66
|
+
return answer !== value;
|
|
67
|
+
case "contains":
|
|
68
|
+
return typeof answer === "string" && typeof value === "string" && answer.includes(value);
|
|
69
|
+
case "is_empty":
|
|
70
|
+
return !answer || answer === "";
|
|
71
|
+
case "is_not_empty":
|
|
72
|
+
return !!answer && answer !== "";
|
|
73
|
+
default:
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildModel(form?: FormData) {
|
|
79
|
+
const sections = [...(form?.sections ?? [])].sort((a, b) => a.order - b.order);
|
|
80
|
+
const byId = new Map(sections.map((s) => [s.id, s]));
|
|
81
|
+
const order = sections.map((s) => s.id);
|
|
82
|
+
|
|
83
|
+
const fieldsBySection = new Map<string, FormField[]>();
|
|
84
|
+
for (const f of form?.fields ?? []) {
|
|
85
|
+
if (!f.sectionId) continue;
|
|
86
|
+
const arr = fieldsBySection.get(f.sectionId) ?? [];
|
|
87
|
+
arr.push(f);
|
|
88
|
+
fieldsBySection.set(f.sectionId, arr);
|
|
89
|
+
}
|
|
90
|
+
for (const arr of fieldsBySection.values()) arr.sort((a, b) => a.order - b.order);
|
|
91
|
+
|
|
92
|
+
const visByTarget = new Map<string, FormFieldEdge[]>();
|
|
93
|
+
const sectionEdges = new Map<string, FormFieldEdge[]>();
|
|
94
|
+
for (const e of form?.edges ?? []) {
|
|
95
|
+
if (e.scope === "section" && e.sourceSectionId) {
|
|
96
|
+
const a = sectionEdges.get(e.sourceSectionId) ?? [];
|
|
97
|
+
a.push(e);
|
|
98
|
+
sectionEdges.set(e.sourceSectionId, a);
|
|
99
|
+
} else if (e.sourceFieldId && e.targetFieldId && e.condition) {
|
|
100
|
+
// A field→field edge with a CONDITION is an in-page visibility rule ("show
|
|
101
|
+
// target when source matches") — treat scope "field"/"visibility" alike
|
|
102
|
+
// since jumps don't apply to paged forms. A field→field edge WITHOUT a
|
|
103
|
+
// condition is not a visibility rule and must NOT hide its target.
|
|
104
|
+
const a = visByTarget.get(e.targetFieldId) ?? [];
|
|
105
|
+
a.push(e);
|
|
106
|
+
visByTarget.set(e.targetFieldId, a);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { sections, byId, order, fieldsBySection, visByTarget, sectionEdges };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Headless multi-section form flow: a paginated wizard over `usePublicForm` that
|
|
114
|
+
* mirrors the server walk (docs/forms-sections.md). Handles current-section
|
|
115
|
+
* state, in-page field visibility (show/hide), per-section required validation,
|
|
116
|
+
* section→section branching (incl. submit), Back navigation, and submit. Bring
|
|
117
|
+
* your own markup (or use the styled `<SectionedFormRenderer>`).
|
|
118
|
+
*
|
|
119
|
+
* For forms with no sections, prefer the flat `<FormRenderer>` — this hook has
|
|
120
|
+
* nothing to page through.
|
|
121
|
+
*/
|
|
122
|
+
export function useSectionedForm(formId?: string) {
|
|
123
|
+
const { data: rawForm, isLoading } = usePublicForm(formId);
|
|
124
|
+
const submitMutation = useSubmitPublicForm(formId);
|
|
125
|
+
|
|
126
|
+
// Seeded per-mount option shuffle for quiz-mode forms (no-op otherwise).
|
|
127
|
+
const seedRef = useRef<string>(Math.random().toString(36).slice(2, 12));
|
|
128
|
+
const form = useMemo(() => shuffleFormOptions(rawForm, seedRef.current), [rawForm]);
|
|
129
|
+
|
|
130
|
+
const model = useMemo(() => buildModel(form), [form]);
|
|
131
|
+
const { sections, order, byId, fieldsBySection, visByTarget, sectionEdges } = model;
|
|
132
|
+
|
|
133
|
+
const [answers, setAnswers] = useState<Record<string, string>>({});
|
|
134
|
+
const [stack, setStack] = useState<string[]>([]); // visited section ids; current = last
|
|
135
|
+
const [error, setError] = useState("");
|
|
136
|
+
const [isCompleted, setIsCompleted] = useState(false);
|
|
137
|
+
// Graded quiz result (null until submit + only for quiz-mode forms). Shaped by
|
|
138
|
+
// the server's reveal policy — see docs/quizzes-certification-initiative.md.
|
|
139
|
+
const [quizResult, setQuizResult] = useState<Record<string, unknown> | null>(null);
|
|
140
|
+
|
|
141
|
+
// Start at the first section once the form loads.
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (form && sections.length && stack.length === 0) setStack([sections[0].id]);
|
|
144
|
+
}, [form, sections, stack.length]);
|
|
145
|
+
|
|
146
|
+
const currentId = stack.length ? stack[stack.length - 1] : null;
|
|
147
|
+
const currentSection = currentId ? byId.get(currentId) ?? null : null;
|
|
148
|
+
|
|
149
|
+
const setAnswer = (id: string, value: string) => setAnswers((p) => ({ ...p, [id]: value }));
|
|
150
|
+
|
|
151
|
+
const isActive = (field: FormField): boolean => {
|
|
152
|
+
const incoming = visByTarget.get(field.id);
|
|
153
|
+
if (!incoming || incoming.length === 0) return true;
|
|
154
|
+
return incoming.some((e) => evaluateCondition(e.condition, answers[e.sourceFieldId ?? ""]));
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const visibleFields = currentId ? (fieldsBySection.get(currentId) ?? []).filter(isActive) : [];
|
|
158
|
+
|
|
159
|
+
const computeNext = (): { submit: boolean; sectionId: string | null } => {
|
|
160
|
+
if (!currentSection) return { submit: true, sectionId: null };
|
|
161
|
+
const outs = (sectionEdges.get(currentSection.id) ?? []).slice().sort((a, b) => a.sortOrder - b.sortOrder);
|
|
162
|
+
const branchAnswer = currentSection.branchFieldId ? answers[currentSection.branchFieldId] : undefined;
|
|
163
|
+
for (const e of outs) {
|
|
164
|
+
if (!e.condition) continue;
|
|
165
|
+
if (evaluateCondition(e.condition, branchAnswer)) {
|
|
166
|
+
return e.targetKind === "submit" ? { submit: true, sectionId: null } : { submit: false, sectionId: e.targetSectionId ?? null };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const def = outs.find((e) => !e.condition);
|
|
170
|
+
if (def) {
|
|
171
|
+
return def.targetKind === "submit" ? { submit: true, sectionId: null } : { submit: false, sectionId: def.targetSectionId ?? null };
|
|
172
|
+
}
|
|
173
|
+
const idx = order.indexOf(currentSection.id);
|
|
174
|
+
const nextId = idx >= 0 && idx + 1 < order.length ? order[idx + 1] : null;
|
|
175
|
+
return { submit: nextId === null, sectionId: nextId };
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const validateCurrent = (): boolean => {
|
|
179
|
+
for (const f of visibleFields) {
|
|
180
|
+
if (f.isRequired && !answers[f.id]) {
|
|
181
|
+
setError(`"${f.label}" is required`);
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
if (f.type === "email" && answers[f.id] && !emailRe.test(answers[f.id])) {
|
|
185
|
+
setError("Please enter a valid email address");
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
setError("");
|
|
190
|
+
return true;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const submit = async () => {
|
|
194
|
+
// Send answers only for active fields in the sections actually visited.
|
|
195
|
+
const visited = stack.length ? stack : sections[0] ? [sections[0].id] : [];
|
|
196
|
+
const entries: { fieldId: string; value: string | null }[] = [];
|
|
197
|
+
for (const sid of visited) {
|
|
198
|
+
for (const f of fieldsBySection.get(sid) ?? []) {
|
|
199
|
+
if (isActive(f)) entries.push({ fieldId: f.id, value: answers[f.id] || null });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const res = (await submitMutation.mutateAsync(entries)) as { result?: Record<string, unknown> | null };
|
|
204
|
+
if (res?.result) setQuizResult(res.result);
|
|
205
|
+
setIsCompleted(true);
|
|
206
|
+
} catch {
|
|
207
|
+
setError("Failed to submit. Please try again.");
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const next = async () => {
|
|
212
|
+
if (!validateCurrent()) return;
|
|
213
|
+
const { submit: doSubmit, sectionId } = computeNext();
|
|
214
|
+
if (doSubmit || !sectionId) {
|
|
215
|
+
await submit();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
setError("");
|
|
219
|
+
setStack((s) => [...s, sectionId]);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const back = () => {
|
|
223
|
+
setError("");
|
|
224
|
+
setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const nextAction = computeNext();
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
form,
|
|
231
|
+
isLoading,
|
|
232
|
+
/** The section currently on screen. */
|
|
233
|
+
currentSection,
|
|
234
|
+
/** Fields of the current section that pass their visibility conditions. */
|
|
235
|
+
visibleFields,
|
|
236
|
+
answers,
|
|
237
|
+
setAnswer,
|
|
238
|
+
error,
|
|
239
|
+
/** Validate the page, then branch to the next section or submit. */
|
|
240
|
+
next,
|
|
241
|
+
/** Return to the previous visited section. */
|
|
242
|
+
back,
|
|
243
|
+
canGoBack: stack.length > 1,
|
|
244
|
+
/** True when Next will submit the form (terminal section / branch). */
|
|
245
|
+
isSubmitStep: nextAction.submit,
|
|
246
|
+
isSubmitting: submitMutation.isPending,
|
|
247
|
+
isCompleted,
|
|
248
|
+
/** Graded quiz result (null for non-quiz forms). Present after submit. */
|
|
249
|
+
quizResult,
|
|
250
|
+
/** 1-based index of the current page in the visited path. */
|
|
251
|
+
step: stack.length,
|
|
252
|
+
totalSections: sections.length,
|
|
253
|
+
/** Estimated 0..1 progress (branching can skip sections). */
|
|
254
|
+
progress: sections.length ? Math.min(1, stack.length / sections.length) : 0,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Tier-1 headless primitives: behavior + a11y, zero styling (compound + asChild,
|
|
2
|
+
// plus behavior hooks for leaf flows like forms).
|
|
3
|
+
export * from "./dialog";
|
|
4
|
+
export * from "./donation";
|
|
5
|
+
export * from "./offer";
|
|
6
|
+
export { MembershipGate, type MembershipGateProps, type MembershipGateState } from "./membership/MembershipGate";
|
|
7
|
+
export { useEmailListForm, type UseEmailListFormOptions, type FormStatus } from "./forms/useEmailListForm";
|
|
8
|
+
export { useContactForm } from "./forms/useContactForm";
|
|
9
|
+
export { useSectionedForm } from "./forms/useSectionedForm";
|
|
10
|
+
export { usePostEngagement, type UsePostEngagementOptions } from "./engagement/usePostEngagement";
|
|
11
|
+
export {
|
|
12
|
+
useCheckout,
|
|
13
|
+
CHECKOUT_STAGE_ORDER,
|
|
14
|
+
type UseCheckoutOptions,
|
|
15
|
+
type CheckoutStage,
|
|
16
|
+
type GuestUserData,
|
|
17
|
+
type CheckoutShippingData,
|
|
18
|
+
type SelectedShippingRate,
|
|
19
|
+
} from "./checkout/useCheckout";
|
|
20
|
+
export { useLoginFlow, type LoginStep } from "./auth/useLoginFlow";
|
|
21
|
+
export { useSignupForm, type UseSignupFormOptions } from "./auth/useSignupForm";
|
|
22
|
+
|
|
23
|
+
// Flow primitives (Part A2) — multi-step purchase/booking/subscribe/chat flows,
|
|
24
|
+
// each composed from the data hooks so a creator can rebuild any page.
|
|
25
|
+
export { useEventCheckout, type UseEventCheckoutOptions, type EventCheckoutStep } from "./event/useEventCheckout";
|
|
26
|
+
export { useCourseCheckout, type UseCourseCheckoutOptions, type CourseCheckoutStep } from "./course/useCourseCheckout";
|
|
27
|
+
export {
|
|
28
|
+
useCoachingBooking,
|
|
29
|
+
type UseCoachingBookingOptions,
|
|
30
|
+
type CoachingBookingStep,
|
|
31
|
+
} from "./coaching/useCoachingBooking";
|
|
32
|
+
export {
|
|
33
|
+
useMembershipCheckout,
|
|
34
|
+
type UseMembershipCheckoutOptions,
|
|
35
|
+
type MembershipCheckoutStep,
|
|
36
|
+
type BillingCycle,
|
|
37
|
+
} from "./membership/useMembershipCheckout";
|
|
38
|
+
export { useInvoicePayment, type UseInvoicePaymentOptions } from "./invoice/useInvoicePayment";
|
|
39
|
+
export { usePaymentLinkPayment, type UsePaymentLinkPaymentOptions } from "./paymentLink/usePaymentLinkPayment";
|
|
40
|
+
export { useBroadcastWatch } from "./broadcast/useBroadcastWatch";
|
|
41
|
+
export { useChatRoom } from "./chat/useChatRoom";
|
|
42
|
+
export { useMessageThread, type UseMessageThreadResult } from "./chat/useMessageThread";
|
|
43
|
+
export { useChatAttachmentUpload, type PendingAttachment } from "./chat/useChatAttachmentUpload";
|
|
44
|
+
export { attachmentKind, formatBytes, type AttachmentKind } from "./chat/attachmentKind";
|
|
45
|
+
export { useCookieConsent, type UseCookieConsentResult } from "./consent/useCookieConsent";
|
|
46
|
+
|
|
47
|
+
export { usePodcastPlayer, type UsePodcastPlayerOptions, type PodcastPlayerState } from "./podcast/usePodcastPlayer";
|
|
48
|
+
|
|
49
|
+
// Community (forum) headless primitives.
|
|
50
|
+
export * from "./community";
|
|
51
|
+
// Member posts pillar (member home) headless primitives.
|
|
52
|
+
export * from "./posts";
|
|
53
|
+
// Unified member home nav model.
|
|
54
|
+
export * from "./memberHome";
|
|
55
|
+
// Reviews (social proof) headless primitives.
|
|
56
|
+
export * from "./reviews";
|
|
57
|
+
// Document signing (contracts / e-signature) headless primitives.
|
|
58
|
+
export * from "./document";
|
|
59
|
+
// Work (project management) client-portal headless primitives.
|
|
60
|
+
export * from "./work";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useInvoice, useDownloadInvoicePdf } from "../../../data/queries/useInvoice";
|
|
2
|
+
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
3
|
+
|
|
4
|
+
export interface UseInvoicePaymentOptions {
|
|
5
|
+
/** Return path after payment. Default `/i/invoice/:id/finalise`. */
|
|
6
|
+
returnPath?: string;
|
|
7
|
+
/** Start payment as soon as the invoice loads (default true). */
|
|
8
|
+
autoStart?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Headless invoice payment: loads the invoice, starts its payment, and exposes
|
|
13
|
+
* the PDF download. Composes `useInvoice`, `useDownloadInvoicePdf`,
|
|
14
|
+
* `usePaymentFlow`. Stripe UI renders from `clientSecret`; finalize on the
|
|
15
|
+
* return page with `useInvoiceFinalize`.
|
|
16
|
+
*/
|
|
17
|
+
export function useInvoicePayment(invoiceId?: string, opts: UseInvoicePaymentOptions = {}) {
|
|
18
|
+
const invoice = useInvoice(invoiceId);
|
|
19
|
+
const downloadPdf = useDownloadInvoicePdf(invoiceId);
|
|
20
|
+
|
|
21
|
+
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
22
|
+
const returnUrl = invoiceId ? `${origin}${opts.returnPath ?? `/i/invoice/${invoiceId}/finalise`}` : "";
|
|
23
|
+
|
|
24
|
+
const flow = usePaymentFlow({
|
|
25
|
+
path: `/public/invoices/${invoiceId}/start-payment`,
|
|
26
|
+
returnUrl,
|
|
27
|
+
autoStart: opts.autoStart ?? true,
|
|
28
|
+
enabled: !!invoiceId && !!invoice.data,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
invoice,
|
|
33
|
+
downloadPdf,
|
|
34
|
+
returnUrl,
|
|
35
|
+
clientSecret: flow.clientSecret,
|
|
36
|
+
paymentId: flow.paymentId,
|
|
37
|
+
provider: flow.provider,
|
|
38
|
+
/** Sales-tax quote (built from the invoice's own tax columns — no re-quote). */
|
|
39
|
+
taxQuote: flow.result?.taxQuote ?? null,
|
|
40
|
+
isStarting: flow.isStarting,
|
|
41
|
+
error: flow.error
|
|
42
|
+
? ((flow.error as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
|
43
|
+
"Payment could not be started.")
|
|
44
|
+
: null,
|
|
45
|
+
/** Re-start payment (e.g. retry after failure). */
|
|
46
|
+
pay: flow.start,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { useGetPostCollections } from "../../../data/queries/usePostCollections";
|
|
3
|
+
import { useCommunitySpaces } from "../../../data/queries/useCommunity";
|
|
4
|
+
import { useChatChannels } from "../../../data/queries/useChat";
|
|
5
|
+
|
|
6
|
+
export type MemberHomeNavKey = "posts" | "collections" | "spaces" | "chat" | "membership";
|
|
7
|
+
|
|
8
|
+
export interface MemberHomeNavItem {
|
|
9
|
+
key: MemberHomeNavKey;
|
|
10
|
+
label: string;
|
|
11
|
+
/** Absolute in-site href (already run through `buildHref` when supplied). */
|
|
12
|
+
href: string;
|
|
13
|
+
/** Conditional tabs (Collections/Spaces/Chat) are hidden until the profile has content for them. */
|
|
14
|
+
visible: boolean;
|
|
15
|
+
/** Whether `currentPath` falls inside this tab. */
|
|
16
|
+
active: boolean;
|
|
17
|
+
/** True for "My membership" — a deep link out of the member home (account membership tab). */
|
|
18
|
+
external: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface UseMemberHomeNavOptions {
|
|
22
|
+
/** Current location pathname, used to compute `active`. */
|
|
23
|
+
currentPath?: string;
|
|
24
|
+
/** Member home base path (default `/i/members` — identical in both renderers). */
|
|
25
|
+
basePath?: string;
|
|
26
|
+
/** "My membership" target. Client: `/account?tab=membership` (default). Site-starter: `/i/account?tab=membership`. */
|
|
27
|
+
membershipHref?: string;
|
|
28
|
+
/** Optional href transform (e.g. locale prefixes). Applied to every item. */
|
|
29
|
+
buildHref?: (href: string) => string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const normalize = (p: string) => {
|
|
33
|
+
const path = (p.split("?")[0] ?? "").replace(/\/+$/, "");
|
|
34
|
+
return path === "" ? "/" : path;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Headless nav model for the unified member home (`/i/members`). Tab
|
|
39
|
+
* visibility derives client-side from queries the pages already need (cache
|
|
40
|
+
* shared via React Query): Posts is always visible; Collections appears when
|
|
41
|
+
* the profile has any post collections; Spaces when any community spaces are
|
|
42
|
+
* accessible; Chat when any chat channels are accessible; "My membership" is
|
|
43
|
+
* always visible and deep-links out to the account membership tab. Hosts own
|
|
44
|
+
* routing — pair with any layout via `href`/`active`.
|
|
45
|
+
*/
|
|
46
|
+
export function useMemberHomeNav(opts: UseMemberHomeNavOptions = {}) {
|
|
47
|
+
const basePath = opts.basePath ?? "/i/members";
|
|
48
|
+
const membershipHref = opts.membershipHref ?? "/account?tab=membership";
|
|
49
|
+
const buildHref = opts.buildHref ?? ((href: string) => href);
|
|
50
|
+
const currentPath = normalize(opts.currentPath ?? "");
|
|
51
|
+
|
|
52
|
+
const collectionsQ = useGetPostCollections({ page: 1 });
|
|
53
|
+
const spacesQ = useCommunitySpaces();
|
|
54
|
+
const channelsQ = useChatChannels();
|
|
55
|
+
|
|
56
|
+
const hasCollections = (collectionsQ.data?.data?.length ?? 0) > 0;
|
|
57
|
+
const hasSpaces = (spacesQ.data?.length ?? 0) > 0;
|
|
58
|
+
const hasChannels = (channelsQ.data?.length ?? 0) > 0;
|
|
59
|
+
|
|
60
|
+
const items: MemberHomeNavItem[] = useMemo(() => {
|
|
61
|
+
const isUnder = (segment: string) =>
|
|
62
|
+
currentPath === normalize(`${basePath}/${segment}`) || currentPath.startsWith(normalize(`${basePath}/${segment}`) + "/");
|
|
63
|
+
const defs: Array<Omit<MemberHomeNavItem, "href" | "active"> & { path: string; active: boolean }> = [
|
|
64
|
+
{ key: "posts", label: "Posts", path: basePath, visible: true, external: false, active: currentPath === normalize(basePath) },
|
|
65
|
+
{ key: "collections", label: "Collections", path: `${basePath}/collections`, visible: hasCollections, external: false, active: isUnder("collections") },
|
|
66
|
+
{ key: "spaces", label: "Spaces", path: `${basePath}/spaces`, visible: hasSpaces, external: false, active: isUnder("spaces") },
|
|
67
|
+
{ key: "chat", label: "Chat", path: `${basePath}/chat`, visible: hasChannels, external: false, active: isUnder("chat") },
|
|
68
|
+
{ key: "membership", label: "My membership", path: membershipHref, visible: true, external: true, active: false },
|
|
69
|
+
];
|
|
70
|
+
return defs.map(({ path, ...d }) => ({ ...d, href: buildHref(path) }));
|
|
71
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
72
|
+
}, [basePath, membershipHref, currentPath, hasCollections, hasSpaces, hasChannels]);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
items,
|
|
76
|
+
/** Visible items only — what a shell actually renders. */
|
|
77
|
+
visibleItems: items.filter((i) => i.visible),
|
|
78
|
+
activeKey: items.find((i) => i.active)?.key ?? null,
|
|
79
|
+
/** True once the three visibility queries have settled (avoids tab flash). */
|
|
80
|
+
isReady: !collectionsQ.isLoading && !spacesQ.isLoading && !channelsQ.isLoading,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
3
|
+
|
|
4
|
+
export interface MembershipGateState {
|
|
5
|
+
hasAccess: boolean;
|
|
6
|
+
isAuthenticated: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface MembershipGateProps {
|
|
10
|
+
/**
|
|
11
|
+
* Tier ids that unlock the content. Omitted/empty → any active membership
|
|
12
|
+
* unlocks. (For per-item gates where the server already returns `hasAccess`,
|
|
13
|
+
* pass it via `access`.)
|
|
14
|
+
*/
|
|
15
|
+
tiers?: string[];
|
|
16
|
+
/** Override the access decision (e.g. server-provided `post.hasAccess`). */
|
|
17
|
+
access?: boolean;
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
/** Rendered when locked — static node or a render-fn given the gate state. */
|
|
20
|
+
fallback?: ReactNode | ((state: MembershipGateState) => ReactNode);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Headless access gate. Renders children when the member has access, otherwise
|
|
25
|
+
* the fallback. Behavior (who has access) is fixed by the server/membership;
|
|
26
|
+
* the locked UI is entirely yours.
|
|
27
|
+
*/
|
|
28
|
+
export function MembershipGate({ tiers, access, children, fallback }: MembershipGateProps) {
|
|
29
|
+
const { user, isAuthenticated } = usePublicAuth();
|
|
30
|
+
const membership = user?.membership;
|
|
31
|
+
const activeMembership = !!membership && membership.status === "active";
|
|
32
|
+
const tierAllows = !tiers?.length || (membership ? tiers.includes(membership.membershipTierId) : false);
|
|
33
|
+
|
|
34
|
+
const hasAccess = access ?? (activeMembership && tierAllows);
|
|
35
|
+
|
|
36
|
+
if (hasAccess) return <>{children}</>;
|
|
37
|
+
if (typeof fallback === "function") return <>{fallback({ hasAccess, isAuthenticated })}</>;
|
|
38
|
+
return <>{fallback ?? null}</>;
|
|
39
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
3
|
+
import { useGetMembershipTiers } from "../../../data/queries/useMembership";
|
|
4
|
+
import {
|
|
5
|
+
useCreateSubscription,
|
|
6
|
+
useCreateFreeSubscription,
|
|
7
|
+
useConfirmLatestSubscription,
|
|
8
|
+
} from "../../../data/queries/useSubscriptions";
|
|
9
|
+
import { readAttributionRef } from "../../../utils/attribution";
|
|
10
|
+
import { readLanding } from "../../../utils/landing";
|
|
11
|
+
|
|
12
|
+
export type MembershipCheckoutStep = "select" | "payment";
|
|
13
|
+
export type BillingCycle = "month" | "year";
|
|
14
|
+
|
|
15
|
+
export interface UseMembershipCheckoutOptions {
|
|
16
|
+
/** Pre-select a tier (e.g. from a `?membershipTierId=` param). */
|
|
17
|
+
initialTierId?: string;
|
|
18
|
+
/** Where Stripe returns the member after a paid subscription. Default `/i/account?tab=membership&confirmSubscription=true`. */
|
|
19
|
+
returnPath?: string;
|
|
20
|
+
/** Where free activations land. Default `/i/account?tab=membership`. */
|
|
21
|
+
freeReturnPath?: string;
|
|
22
|
+
/** Called when a FREE activation completes in-app — the host navigates however
|
|
23
|
+
* it wants, instead of Forge doing a `window.location` redirect. */
|
|
24
|
+
onComplete?: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const errMessage = (e: unknown) =>
|
|
28
|
+
(e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Something went wrong.";
|
|
29
|
+
|
|
30
|
+
const tierIsFree = (tier: { payWhatYouWant: boolean; priceMonthly?: number; priceYearly?: number }, cycle: BillingCycle) =>
|
|
31
|
+
!tier.payWhatYouWant && !(cycle === "month" ? tier.priceMonthly : tier.priceYearly);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Headless membership subscribe: tier selection → free activation OR paid Stripe
|
|
35
|
+
* subscription. Composes `useGetMembershipTiers`, `useCreateFreeSubscription`,
|
|
36
|
+
* `useCreateSubscription`, and (for the return page) `useConfirmLatestSubscription`.
|
|
37
|
+
* Paid flow exposes `clientSecret` for `ForgePaymentProvider`.
|
|
38
|
+
*/
|
|
39
|
+
export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
40
|
+
const { user } = usePublicAuth();
|
|
41
|
+
const { data: tiers, isLoading } = useGetMembershipTiers();
|
|
42
|
+
const createPaid = useCreateSubscription();
|
|
43
|
+
const createFree = useCreateFreeSubscription();
|
|
44
|
+
const confirmLatest = useConfirmLatestSubscription();
|
|
45
|
+
|
|
46
|
+
const [step, setStep] = useState<MembershipCheckoutStep>("select");
|
|
47
|
+
const [selectedTierId, setSelectedTierId] = useState<string | undefined>(opts.initialTierId);
|
|
48
|
+
const [billingCycle, setBillingCycle] = useState<BillingCycle>("month");
|
|
49
|
+
const [customAmount, setCustomAmount] = useState<number>(0);
|
|
50
|
+
const [clientSecret, setClientSecret] = useState<string | undefined>();
|
|
51
|
+
const [error, setError] = useState<string | null>(null);
|
|
52
|
+
|
|
53
|
+
const selectedTier = useMemo(
|
|
54
|
+
() => tiers?.find((t) => t.id === selectedTierId),
|
|
55
|
+
[tiers, selectedTierId],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const amount = useMemo(() => {
|
|
59
|
+
if (!selectedTier) return 0;
|
|
60
|
+
if (selectedTier.payWhatYouWant) return customAmount;
|
|
61
|
+
return (billingCycle === "month" ? selectedTier.priceMonthly : selectedTier.priceYearly) ?? 0;
|
|
62
|
+
}, [selectedTier, billingCycle, customAmount]);
|
|
63
|
+
|
|
64
|
+
const subscribe = async () => {
|
|
65
|
+
setError(null);
|
|
66
|
+
if (!selectedTier) {
|
|
67
|
+
setError("Select a membership tier.");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (!user) {
|
|
71
|
+
setError("Please sign in to subscribe.");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
75
|
+
const attributionRefId = readAttributionRef() ?? undefined;
|
|
76
|
+
const landing = readLanding() ?? {};
|
|
77
|
+
try {
|
|
78
|
+
if (tierIsFree(selectedTier, billingCycle)) {
|
|
79
|
+
await createFree.mutateAsync({ membershipTierId: selectedTier.id, attributionRefId, ...landing });
|
|
80
|
+
if (opts.onComplete) opts.onComplete();
|
|
81
|
+
else if (typeof window !== "undefined") {
|
|
82
|
+
window.location.href = `${origin}${opts.freeReturnPath ?? "/i/account?tab=membership"}`;
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const data = await createPaid.mutateAsync({
|
|
87
|
+
amount,
|
|
88
|
+
billingCycle,
|
|
89
|
+
membershipTierId: selectedTier.id,
|
|
90
|
+
returnUrl: `${origin}${opts.returnPath ?? "/i/account?tab=membership&confirmSubscription=true"}`,
|
|
91
|
+
attributionRefId,
|
|
92
|
+
...landing,
|
|
93
|
+
});
|
|
94
|
+
setClientSecret(data.clientSecret);
|
|
95
|
+
setStep("payment");
|
|
96
|
+
} catch (e) {
|
|
97
|
+
setError(errMessage(e));
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
tiers,
|
|
103
|
+
isLoading,
|
|
104
|
+
step,
|
|
105
|
+
setStep,
|
|
106
|
+
selectedTierId,
|
|
107
|
+
setSelectedTierId,
|
|
108
|
+
selectedTier,
|
|
109
|
+
billingCycle,
|
|
110
|
+
setBillingCycle,
|
|
111
|
+
customAmount,
|
|
112
|
+
setCustomAmount,
|
|
113
|
+
amount,
|
|
114
|
+
subscribe,
|
|
115
|
+
clientSecret,
|
|
116
|
+
isProcessing: createPaid.isPending || createFree.isPending,
|
|
117
|
+
error,
|
|
118
|
+
/** Call on the return page to reconcile the latest subscription. */
|
|
119
|
+
confirmLatest,
|
|
120
|
+
};
|
|
121
|
+
}
|