@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,223 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
|
2
|
+
import type { PaymentFlowResult, PublicDonation } from "../../../types/models";
|
|
3
|
+
import { useGetDonation, useTrackDonationView } from "../../../data/queries/useDonations";
|
|
4
|
+
import { useDonationFinalize } from "../../../data/queries/useFinalize";
|
|
5
|
+
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
6
|
+
import { estimateProcessingFee } from "./fees";
|
|
7
|
+
|
|
8
|
+
export type DonationStep = "amount" | "payment" | "result";
|
|
9
|
+
export type DonationOutcome = "succeeded" | "failed" | null;
|
|
10
|
+
|
|
11
|
+
export interface DonationContextValue {
|
|
12
|
+
donation: PublicDonation | undefined;
|
|
13
|
+
isLoading: boolean;
|
|
14
|
+
|
|
15
|
+
step: DonationStep;
|
|
16
|
+
open: boolean;
|
|
17
|
+
setOpen: (open: boolean) => void;
|
|
18
|
+
|
|
19
|
+
// Amount
|
|
20
|
+
presets: number[];
|
|
21
|
+
minimum: number;
|
|
22
|
+
selectedPreset: number | null;
|
|
23
|
+
setSelectedPreset: (n: number | null) => void;
|
|
24
|
+
customAmount: string;
|
|
25
|
+
setCustomAmount: (s: string) => void;
|
|
26
|
+
effectiveAmount: number;
|
|
27
|
+
coverFees: boolean;
|
|
28
|
+
setCoverFees: (b: boolean) => void;
|
|
29
|
+
estimatedFee: number;
|
|
30
|
+
estimatedTotal: number;
|
|
31
|
+
|
|
32
|
+
// Donor
|
|
33
|
+
firstName: string;
|
|
34
|
+
setFirstName: (s: string) => void;
|
|
35
|
+
lastName: string;
|
|
36
|
+
setLastName: (s: string) => void;
|
|
37
|
+
email: string;
|
|
38
|
+
setEmail: (s: string) => void;
|
|
39
|
+
message: string;
|
|
40
|
+
setMessage: (s: string) => void;
|
|
41
|
+
|
|
42
|
+
// Flow
|
|
43
|
+
isStarting: boolean;
|
|
44
|
+
startError: string | null;
|
|
45
|
+
startResult: PaymentFlowResult | null;
|
|
46
|
+
/** The return URL passed to the payment provider. */
|
|
47
|
+
returnUrl: string;
|
|
48
|
+
continueToPayment: () => Promise<void>;
|
|
49
|
+
|
|
50
|
+
// Result
|
|
51
|
+
outcome: DonationOutcome;
|
|
52
|
+
paymentErrorMessage: string | null;
|
|
53
|
+
onPaymentSucceeded: () => Promise<void>;
|
|
54
|
+
onPaymentFailed: (message?: string) => void;
|
|
55
|
+
reset: () => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DonationContext = createContext<DonationContextValue | null>(null);
|
|
59
|
+
|
|
60
|
+
export function useDonationContext(): DonationContextValue {
|
|
61
|
+
const ctx = useContext(DonationContext);
|
|
62
|
+
if (!ctx) throw new Error("Donation components must be used within <Donation>.");
|
|
63
|
+
return ctx;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface DonationProviderProps {
|
|
67
|
+
donationId: string;
|
|
68
|
+
children: ReactNode;
|
|
69
|
+
/** Where the provider should return after payment. Defaults to /donation/finalise. */
|
|
70
|
+
returnUrl?: string;
|
|
71
|
+
/** Called once a donation is confirmed succeeded. */
|
|
72
|
+
onSuccess?: () => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function DonationProvider({ donationId, children, returnUrl, onSuccess }: DonationProviderProps) {
|
|
76
|
+
const { data: donation, isLoading } = useGetDonation(donationId);
|
|
77
|
+
const trackView = useTrackDonationView();
|
|
78
|
+
const flow = usePaymentFlow({ path: `/public/donations/${donationId}/start-payment`, autoStart: false });
|
|
79
|
+
const donationFinalize = useDonationFinalize();
|
|
80
|
+
|
|
81
|
+
const [open, setOpenState] = useState(false);
|
|
82
|
+
const [step, setStep] = useState<DonationStep>("amount");
|
|
83
|
+
|
|
84
|
+
const presets = useMemo(() => (donation?.defaultAmounts ?? []).filter((v) => v > 0), [donation?.defaultAmounts]);
|
|
85
|
+
const minimum = donation?.minimumAmount != null ? Number(donation.minimumAmount) : 0;
|
|
86
|
+
|
|
87
|
+
const [selectedPreset, setSelectedPreset] = useState<number | null>(null);
|
|
88
|
+
const [customAmount, setCustomAmount] = useState("");
|
|
89
|
+
const [coverFees, setCoverFees] = useState(false);
|
|
90
|
+
const [firstName, setFirstName] = useState("");
|
|
91
|
+
const [lastName, setLastName] = useState("");
|
|
92
|
+
const [email, setEmail] = useState("");
|
|
93
|
+
const [message, setMessage] = useState("");
|
|
94
|
+
const [startError, setStartError] = useState<string | null>(null);
|
|
95
|
+
const [outcome, setOutcome] = useState<DonationOutcome>(null);
|
|
96
|
+
const [paymentErrorMessage, setPaymentErrorMessage] = useState<string | null>(null);
|
|
97
|
+
|
|
98
|
+
const effectiveAmount = useMemo(() => {
|
|
99
|
+
if (selectedPreset != null && !customAmount) return selectedPreset;
|
|
100
|
+
const parsed = Number(customAmount);
|
|
101
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
102
|
+
}, [selectedPreset, customAmount]);
|
|
103
|
+
|
|
104
|
+
const estimatedFee = useMemo(
|
|
105
|
+
() => (donation?.coverFeesEnabled && coverFees ? estimateProcessingFee(effectiveAmount) : 0),
|
|
106
|
+
[donation?.coverFeesEnabled, coverFees, effectiveAmount],
|
|
107
|
+
);
|
|
108
|
+
const estimatedTotal = useMemo(
|
|
109
|
+
() => Math.round((effectiveAmount + estimatedFee) * 100) / 100,
|
|
110
|
+
[effectiveAmount, estimatedFee],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const resolvedReturnUrl =
|
|
114
|
+
returnUrl ?? `${typeof window !== "undefined" ? window.location.origin : ""}/donation/finalise`;
|
|
115
|
+
|
|
116
|
+
const setOpen = (next: boolean) => {
|
|
117
|
+
setOpenState(next);
|
|
118
|
+
if (next && donationId) trackView.mutate({ donationId });
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const reset = () => {
|
|
122
|
+
setStep("amount");
|
|
123
|
+
setOutcome(null);
|
|
124
|
+
setPaymentErrorMessage(null);
|
|
125
|
+
setStartError(null);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const continueToPayment = async () => {
|
|
129
|
+
setStartError(null);
|
|
130
|
+
if (!effectiveAmount || effectiveAmount <= 0) {
|
|
131
|
+
setStartError("Please enter a donation amount.");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (minimum > 0 && effectiveAmount < minimum) {
|
|
135
|
+
setStartError(`Minimum donation is ${minimum}.`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!firstName.trim() || !lastName.trim()) {
|
|
139
|
+
setStartError("Please enter your name.");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (!email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
143
|
+
setStartError("Please enter a valid email.");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
await flow.start({
|
|
148
|
+
amount: effectiveAmount,
|
|
149
|
+
coverFees: coverFees && !!donation?.coverFeesEnabled,
|
|
150
|
+
firstName: firstName.trim(),
|
|
151
|
+
lastName: lastName.trim(),
|
|
152
|
+
email: email.trim(),
|
|
153
|
+
message: message.trim() || null,
|
|
154
|
+
returnUrl: resolvedReturnUrl,
|
|
155
|
+
});
|
|
156
|
+
setStep("payment");
|
|
157
|
+
} catch {
|
|
158
|
+
setStartError("Could not start your donation. Please try again.");
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const onPaymentSucceeded = async () => {
|
|
163
|
+
const donationPaymentId = flow.result?.donationPaymentId;
|
|
164
|
+
if (donationPaymentId) {
|
|
165
|
+
try {
|
|
166
|
+
const data = await donationFinalize.mutateAsync({ donationPaymentId });
|
|
167
|
+
setOutcome(data.status === "succeeded" ? "succeeded" : "failed");
|
|
168
|
+
} catch {
|
|
169
|
+
// The webhook will finalize; optimistically show success.
|
|
170
|
+
setOutcome("succeeded");
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
setOutcome("succeeded");
|
|
174
|
+
}
|
|
175
|
+
setStep("result");
|
|
176
|
+
onSuccess?.();
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const onPaymentFailed = (msg?: string) => {
|
|
180
|
+
setOutcome("failed");
|
|
181
|
+
setPaymentErrorMessage(msg ?? null);
|
|
182
|
+
setStep("result");
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const value: DonationContextValue = {
|
|
186
|
+
donation,
|
|
187
|
+
isLoading,
|
|
188
|
+
step,
|
|
189
|
+
open,
|
|
190
|
+
setOpen,
|
|
191
|
+
presets,
|
|
192
|
+
minimum,
|
|
193
|
+
selectedPreset,
|
|
194
|
+
setSelectedPreset,
|
|
195
|
+
customAmount,
|
|
196
|
+
setCustomAmount,
|
|
197
|
+
effectiveAmount,
|
|
198
|
+
coverFees,
|
|
199
|
+
setCoverFees,
|
|
200
|
+
estimatedFee,
|
|
201
|
+
estimatedTotal,
|
|
202
|
+
firstName,
|
|
203
|
+
setFirstName,
|
|
204
|
+
lastName,
|
|
205
|
+
setLastName,
|
|
206
|
+
email,
|
|
207
|
+
setEmail,
|
|
208
|
+
message,
|
|
209
|
+
setMessage,
|
|
210
|
+
isStarting: flow.isStarting,
|
|
211
|
+
startError,
|
|
212
|
+
startResult: flow.result,
|
|
213
|
+
returnUrl: resolvedReturnUrl,
|
|
214
|
+
continueToPayment,
|
|
215
|
+
outcome,
|
|
216
|
+
paymentErrorMessage,
|
|
217
|
+
onPaymentSucceeded,
|
|
218
|
+
onPaymentFailed,
|
|
219
|
+
reset,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
return <DonationContext.Provider value={value}>{children}</DonationContext.Provider>;
|
|
223
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Pure fee math — no React, no I/O. Kept framework-agnostic so it can move into
|
|
2
|
+
// a future `forge-core` unchanged.
|
|
3
|
+
|
|
4
|
+
export const FEE_PERCENTAGE = 0.029;
|
|
5
|
+
export const FEE_FLAT = 0.3;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The processing fee a donor would cover so the full intended amount reaches the
|
|
9
|
+
* creator (Stripe's 2.9% + $0.30, grossed up).
|
|
10
|
+
*/
|
|
11
|
+
export function estimateProcessingFee(amount: number): number {
|
|
12
|
+
if (!amount || amount <= 0) return 0;
|
|
13
|
+
const total = (amount + FEE_FLAT) / (1 - FEE_PERCENTAGE);
|
|
14
|
+
return Math.round((total - amount) * 100) / 100;
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { Donation, type DonationPaymentRenderProps } from "./Donation";
|
|
2
|
+
export {
|
|
3
|
+
DonationProvider,
|
|
4
|
+
useDonationContext,
|
|
5
|
+
type DonationContextValue,
|
|
6
|
+
type DonationProviderProps,
|
|
7
|
+
type DonationStep,
|
|
8
|
+
type DonationOutcome,
|
|
9
|
+
} from "./DonationContext";
|
|
10
|
+
export { estimateProcessingFee, FEE_PERCENTAGE, FEE_FLAT } from "./fees";
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { EngagementEntityType, IPublicComment } from "../../../types/models";
|
|
3
|
+
import {
|
|
4
|
+
useLikeStatus,
|
|
5
|
+
useSaveStatus,
|
|
6
|
+
useEngagementCounts,
|
|
7
|
+
useComments,
|
|
8
|
+
useToggleLike,
|
|
9
|
+
useToggleSave,
|
|
10
|
+
useAddComment,
|
|
11
|
+
} from "../../../data/queries/useEngagement";
|
|
12
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
13
|
+
|
|
14
|
+
export interface UsePostEngagementOptions {
|
|
15
|
+
/** Called when a logged-out visitor tries to like/save/comment. */
|
|
16
|
+
onRequireAuth?: () => void;
|
|
17
|
+
/** Initial counts (e.g. from the post payload) for instant first paint. */
|
|
18
|
+
initialLikeCount?: number;
|
|
19
|
+
initialCommentCount?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Headless like / save / comment behavior for any entity (post, product, …).
|
|
24
|
+
* Owns the optimistic local state and gates writes on auth. Bring your own UI.
|
|
25
|
+
*/
|
|
26
|
+
export function usePostEngagement(
|
|
27
|
+
entityId: string,
|
|
28
|
+
entityType: EngagementEntityType = "post",
|
|
29
|
+
opts: UsePostEngagementOptions = {},
|
|
30
|
+
) {
|
|
31
|
+
const { isAuthenticated } = usePublicAuth();
|
|
32
|
+
const { data: likedStatus } = useLikeStatus(entityId, entityType, isAuthenticated);
|
|
33
|
+
const { data: savedStatus } = useSaveStatus(entityId, entityType, isAuthenticated);
|
|
34
|
+
const { data: counts } = useEngagementCounts(entityId, entityType, isAuthenticated);
|
|
35
|
+
|
|
36
|
+
const [showComments, setShowComments] = useState(false);
|
|
37
|
+
const { data: comments = [], refetch: refetchComments } = useComments(entityId, entityType, showComments);
|
|
38
|
+
|
|
39
|
+
const toggleLikeM = useToggleLike();
|
|
40
|
+
const toggleSaveM = useToggleSave();
|
|
41
|
+
const addCommentM = useAddComment();
|
|
42
|
+
|
|
43
|
+
const [isLiked, setIsLiked] = useState(false);
|
|
44
|
+
const [isSaved, setIsSaved] = useState(false);
|
|
45
|
+
const [likeCount, setLikeCount] = useState(opts.initialLikeCount ?? 0);
|
|
46
|
+
const [commentCount, setCommentCount] = useState(opts.initialCommentCount ?? 0);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (likedStatus !== undefined) setIsLiked(likedStatus);
|
|
50
|
+
}, [likedStatus]);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (savedStatus !== undefined) setIsSaved(savedStatus);
|
|
53
|
+
}, [savedStatus]);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (counts) {
|
|
56
|
+
setLikeCount(counts.likeCount);
|
|
57
|
+
setCommentCount(counts.commentCount);
|
|
58
|
+
}
|
|
59
|
+
}, [counts]);
|
|
60
|
+
|
|
61
|
+
const requireAuth = (): boolean => {
|
|
62
|
+
if (!isAuthenticated) {
|
|
63
|
+
opts.onRequireAuth?.();
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const toggleLike = async () => {
|
|
70
|
+
if (!requireAuth()) return;
|
|
71
|
+
const next = !isLiked;
|
|
72
|
+
try {
|
|
73
|
+
await toggleLikeM.mutateAsync({ entityId, entityType, liked: next });
|
|
74
|
+
setIsLiked(next);
|
|
75
|
+
setLikeCount((c) => c + (next ? 1 : -1));
|
|
76
|
+
} catch {
|
|
77
|
+
/* keep optimistic state untouched on failure */
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const toggleSave = async () => {
|
|
82
|
+
if (!requireAuth()) return;
|
|
83
|
+
const next = !isSaved;
|
|
84
|
+
try {
|
|
85
|
+
await toggleSaveM.mutateAsync({ entityId, entityType, saved: next });
|
|
86
|
+
setIsSaved(next);
|
|
87
|
+
} catch {
|
|
88
|
+
/* noop */
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const openComments = () => {
|
|
93
|
+
if (!requireAuth()) return;
|
|
94
|
+
setShowComments(true);
|
|
95
|
+
refetchComments();
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const addComment = async (content: string) => {
|
|
99
|
+
if (!requireAuth() || !content.trim()) return;
|
|
100
|
+
await addCommentM.mutateAsync({ entityId, entityType, content });
|
|
101
|
+
await refetchComments();
|
|
102
|
+
setCommentCount((c) => c + 1);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
isAuthenticated,
|
|
107
|
+
isLiked,
|
|
108
|
+
likeCount,
|
|
109
|
+
toggleLike,
|
|
110
|
+
isSaved,
|
|
111
|
+
toggleSave,
|
|
112
|
+
commentCount,
|
|
113
|
+
showComments,
|
|
114
|
+
openComments,
|
|
115
|
+
comments: comments as IPublicComment[],
|
|
116
|
+
addComment,
|
|
117
|
+
isAddingComment: addCommentM.isPending,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
3
|
+
import { useEvent, useCreateEventOrder } from "../../../data/queries/useEvents";
|
|
4
|
+
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
5
|
+
import { readAttributionRef } from "../../../utils/attribution";
|
|
6
|
+
import { readLanding } from "../../../utils/landing";
|
|
7
|
+
|
|
8
|
+
export type EventCheckoutStep = "tickets" | "details" | "payment";
|
|
9
|
+
|
|
10
|
+
export interface UseEventCheckoutOptions {
|
|
11
|
+
/** The URL Stripe returns to after a PAID purchase (its `return_url`). Default
|
|
12
|
+
* `/i/events/:slug/finalise`. A real URL is required for the hosted-redirect
|
|
13
|
+
* leg, so this can't be a callback. */
|
|
14
|
+
finalisePath?: (slug: string, orderId: string) => string;
|
|
15
|
+
/** Called when a FREE order completes in-app — the host navigates however it
|
|
16
|
+
* wants (e.g. its router), instead of Forge doing a `window.location` redirect.
|
|
17
|
+
* Omit to keep the default redirect. */
|
|
18
|
+
onComplete?: (info: { slug: string; orderId: string }) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const errMessage = (e: unknown) =>
|
|
22
|
+
(e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Something went wrong.";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Headless event ticket purchase: ticket selection → buyer details → payment.
|
|
26
|
+
* Composes `useEvent`, `useCreateEventOrder`, `usePaymentFlow`. The Stripe UI is
|
|
27
|
+
* rendered by `ForgePaymentProvider` from the exposed `clientSecret`; free orders
|
|
28
|
+
* redirect straight to the finalise page. Finalize via `useEventOrderFinalize`.
|
|
29
|
+
*/
|
|
30
|
+
export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions = {}) {
|
|
31
|
+
const { user } = usePublicAuth();
|
|
32
|
+
// The detail is resolved by slug; the order is created against the real event
|
|
33
|
+
// id (the orders endpoint looks the event up by id, not slug).
|
|
34
|
+
const { data: event, isLoading } = useEvent(slug);
|
|
35
|
+
const eventId = event?.id;
|
|
36
|
+
const createOrder = useCreateEventOrder(eventId);
|
|
37
|
+
const flow = usePaymentFlow({ path: `/public/events/${eventId}/start-payment`, autoStart: false });
|
|
38
|
+
|
|
39
|
+
const [step, setStep] = useState<EventCheckoutStep>("tickets");
|
|
40
|
+
const [selectedTickets, setSelectedTickets] = useState<Record<string, number>>({});
|
|
41
|
+
const [firstName, setFirstName] = useState(user?.firstName ?? "");
|
|
42
|
+
const [lastName, setLastName] = useState(user?.lastName ?? "");
|
|
43
|
+
const [email, setEmail] = useState(user?.email ?? "");
|
|
44
|
+
const [questionnaire, setQuestionnaire] = useState<unknown>(undefined);
|
|
45
|
+
const [returnUrl, setReturnUrl] = useState("");
|
|
46
|
+
const [error, setError] = useState<string | null>(null);
|
|
47
|
+
|
|
48
|
+
const totalAmount = useMemo(() => {
|
|
49
|
+
if (!event) return 0;
|
|
50
|
+
return event.tickets.reduce((sum, t) => sum + t.price * (selectedTickets[t.id] ?? 0), 0);
|
|
51
|
+
}, [event, selectedTickets]);
|
|
52
|
+
|
|
53
|
+
const ticketCount = useMemo(
|
|
54
|
+
() => Object.values(selectedTickets).reduce((a, b) => a + b, 0),
|
|
55
|
+
[selectedTickets],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Keep only positive quantities in state — a ticket dropped to 0 is removed
|
|
59
|
+
// entirely, so `items` never carries a 0-quantity entry (the orders endpoint
|
|
60
|
+
// rejects those with "quantity must be positive").
|
|
61
|
+
const setTicketQty = (ticketId: string, qty: number) =>
|
|
62
|
+
setSelectedTickets((prev) => {
|
|
63
|
+
const next = { ...prev };
|
|
64
|
+
if (qty > 0) next[ticketId] = qty;
|
|
65
|
+
else delete next[ticketId];
|
|
66
|
+
return next;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const goToDetails = () => {
|
|
70
|
+
if (ticketCount === 0) {
|
|
71
|
+
setError("Select at least one ticket.");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
setError(null);
|
|
75
|
+
setStep("details");
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const finalisePath = opts.finalisePath ?? ((s, orderId) => `/i/events/${s}/finalise?orderId=${orderId}`);
|
|
79
|
+
|
|
80
|
+
const continueToPayment = async () => {
|
|
81
|
+
setError(null);
|
|
82
|
+
if (!slug || !eventId) return;
|
|
83
|
+
if (!firstName.trim() || !lastName.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
84
|
+
setError("Please enter your name and a valid email.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const data = await createOrder.mutateAsync({
|
|
89
|
+
items: selectedTickets,
|
|
90
|
+
email,
|
|
91
|
+
firstName,
|
|
92
|
+
lastName,
|
|
93
|
+
questionnaire,
|
|
94
|
+
attributionRefId: readAttributionRef() ?? undefined,
|
|
95
|
+
...(readLanding() ?? {}),
|
|
96
|
+
});
|
|
97
|
+
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
98
|
+
const ru = `${origin}${finalisePath(slug, data.orderId)}`;
|
|
99
|
+
setReturnUrl(ru);
|
|
100
|
+
if (data.isFreeCheckout) {
|
|
101
|
+
// Free order — no payment. Hand navigation to the host if it asked, else
|
|
102
|
+
// default to an in-app redirect to the finalise page.
|
|
103
|
+
if (opts.onComplete) opts.onComplete({ slug, orderId: data.orderId });
|
|
104
|
+
else if (typeof window !== "undefined") window.location.href = ru;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const result = await flow.start({ orderId: data.orderId, returnUrl: ru });
|
|
108
|
+
if (result.provider && result.provider !== "stripe") return; // Paystack redirected
|
|
109
|
+
setStep("payment");
|
|
110
|
+
} catch (e) {
|
|
111
|
+
setError(errMessage(e));
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
event,
|
|
117
|
+
isLoading,
|
|
118
|
+
step,
|
|
119
|
+
setStep,
|
|
120
|
+
selectedTickets,
|
|
121
|
+
setTicketQty,
|
|
122
|
+
ticketCount,
|
|
123
|
+
totalAmount: flow.result?.totalAmount ?? totalAmount,
|
|
124
|
+
/** Authoritative sales-tax quote from start-payment (display only). */
|
|
125
|
+
taxQuote: flow.result?.taxQuote ?? null,
|
|
126
|
+
firstName,
|
|
127
|
+
setFirstName,
|
|
128
|
+
lastName,
|
|
129
|
+
setLastName,
|
|
130
|
+
email,
|
|
131
|
+
setEmail,
|
|
132
|
+
questionnaire,
|
|
133
|
+
setQuestionnaire,
|
|
134
|
+
goToDetails,
|
|
135
|
+
continueToPayment,
|
|
136
|
+
clientSecret: flow.clientSecret,
|
|
137
|
+
returnUrl,
|
|
138
|
+
isProcessing: createOrder.isPending || flow.isStarting,
|
|
139
|
+
error,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useSubmitContactForm } from "../../../data/queries/useWebsite";
|
|
3
|
+
import type { FormStatus } from "./useEmailListForm";
|
|
4
|
+
|
|
5
|
+
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Headless website contact-form behavior: form state + validation + submit.
|
|
9
|
+
* Bring your own markup (or use the styled <ContactForm>).
|
|
10
|
+
*/
|
|
11
|
+
export function useContactForm() {
|
|
12
|
+
const submitForm = useSubmitContactForm();
|
|
13
|
+
const [name, setName] = useState("");
|
|
14
|
+
const [email, setEmail] = useState("");
|
|
15
|
+
const [message, setMessage] = useState("");
|
|
16
|
+
const [joinEmailList, setJoinEmailList] = useState(false);
|
|
17
|
+
const [status, setStatus] = useState<FormStatus>("idle");
|
|
18
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
19
|
+
|
|
20
|
+
const submit = async () => {
|
|
21
|
+
const next: Record<string, string> = {};
|
|
22
|
+
if (!name.trim()) next.name = "Name is required";
|
|
23
|
+
if (!emailRe.test(email)) next.email = "Invalid email address";
|
|
24
|
+
if (message.trim().length < 10) next.message = "Message must be at least 10 characters";
|
|
25
|
+
setErrors(next);
|
|
26
|
+
if (Object.keys(next).length > 0) return;
|
|
27
|
+
|
|
28
|
+
setStatus("submitting");
|
|
29
|
+
try {
|
|
30
|
+
await submitForm.mutateAsync({
|
|
31
|
+
name: name.trim(),
|
|
32
|
+
email: email.trim(),
|
|
33
|
+
message: message.trim(),
|
|
34
|
+
joinEmailList: joinEmailList || undefined,
|
|
35
|
+
});
|
|
36
|
+
setStatus("success");
|
|
37
|
+
} catch {
|
|
38
|
+
setErrors({ form: "Failed to send message. Please try again." });
|
|
39
|
+
setStatus("error");
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
name,
|
|
45
|
+
setName,
|
|
46
|
+
email,
|
|
47
|
+
setEmail,
|
|
48
|
+
message,
|
|
49
|
+
setMessage,
|
|
50
|
+
joinEmailList,
|
|
51
|
+
setJoinEmailList,
|
|
52
|
+
status,
|
|
53
|
+
errors,
|
|
54
|
+
submit,
|
|
55
|
+
isSubmitting: status === "submitting",
|
|
56
|
+
isSuccess: status === "success",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useJoinEmailList } from "../../../data/queries/useLeadMagnet";
|
|
3
|
+
|
|
4
|
+
export type FormStatus = "idle" | "submitting" | "success" | "error";
|
|
5
|
+
|
|
6
|
+
export interface UseEmailListFormOptions {
|
|
7
|
+
emailListId?: string;
|
|
8
|
+
leadMagnetId?: string;
|
|
9
|
+
includeName?: boolean;
|
|
10
|
+
includePhoneNumber?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
14
|
+
const phoneRe = /^\+?[0-9\s\-()]{7,20}$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Headless email-list-join behavior: form state + validation + the join
|
|
18
|
+
* mutation. Bring your own markup (or use the styled <EmailListForm>).
|
|
19
|
+
*/
|
|
20
|
+
export function useEmailListForm(opts: UseEmailListFormOptions = {}) {
|
|
21
|
+
const join = useJoinEmailList();
|
|
22
|
+
const [email, setEmail] = useState("");
|
|
23
|
+
const [name, setName] = useState("");
|
|
24
|
+
const [phoneNumber, setPhoneNumber] = useState("");
|
|
25
|
+
const [status, setStatus] = useState<FormStatus>("idle");
|
|
26
|
+
const [error, setError] = useState<string | null>(null);
|
|
27
|
+
const [requiresConfirmation, setRequiresConfirmation] = useState(false);
|
|
28
|
+
|
|
29
|
+
const submit = async () => {
|
|
30
|
+
setError(null);
|
|
31
|
+
if (!emailRe.test(email)) {
|
|
32
|
+
setError("Please enter a valid email");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (opts.includeName && !name.trim()) {
|
|
36
|
+
setError("Please enter your name");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (opts.includePhoneNumber && (!phoneNumber.trim() || !phoneRe.test(phoneNumber.trim()))) {
|
|
40
|
+
setError("Please enter a valid phone number");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
setStatus("submitting");
|
|
44
|
+
try {
|
|
45
|
+
const data = await join.mutateAsync({
|
|
46
|
+
email: email.trim().toLowerCase(),
|
|
47
|
+
emailListId: opts.emailListId,
|
|
48
|
+
leadMagnetId: opts.leadMagnetId,
|
|
49
|
+
firstName: opts.includeName ? name.trim() : undefined,
|
|
50
|
+
phoneNumber: opts.includePhoneNumber ? phoneNumber.trim() : undefined,
|
|
51
|
+
});
|
|
52
|
+
setRequiresConfirmation(!!data?.requiresConfirmation);
|
|
53
|
+
setStatus("success");
|
|
54
|
+
} catch (e) {
|
|
55
|
+
const message = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
56
|
+
setError(message || "Something went wrong");
|
|
57
|
+
setStatus("error");
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
email,
|
|
63
|
+
setEmail,
|
|
64
|
+
name,
|
|
65
|
+
setName,
|
|
66
|
+
phoneNumber,
|
|
67
|
+
setPhoneNumber,
|
|
68
|
+
status,
|
|
69
|
+
error,
|
|
70
|
+
requiresConfirmation,
|
|
71
|
+
submit,
|
|
72
|
+
isSubmitting: status === "submitting",
|
|
73
|
+
isSuccess: status === "success",
|
|
74
|
+
};
|
|
75
|
+
}
|