@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,438 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useCart } from "../../../contexts/CartContext";
|
|
3
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
4
|
+
import { useCreateOrder, type CreateOrderShippingRate, type CreateOrderResult } from "../../../data/queries/useOrders";
|
|
5
|
+
import {
|
|
6
|
+
useShippingCountries,
|
|
7
|
+
useShippingRates,
|
|
8
|
+
useApplyCoupon,
|
|
9
|
+
type ShippingGroup,
|
|
10
|
+
type ShippingRate,
|
|
11
|
+
type ShippingCountry,
|
|
12
|
+
} from "../../../data/queries/useShipping";
|
|
13
|
+
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
14
|
+
import { readAttributionRef } from "../../../utils/attribution";
|
|
15
|
+
import { readLanding } from "../../../utils/landing";
|
|
16
|
+
import { ProductDeliveryType, PaymentProviderName, type ApiError, type PublicTaxQuote } from "../../../types/models";
|
|
17
|
+
|
|
18
|
+
/** The four checkout stages (guest details are skipped for a logged-in buyer;
|
|
19
|
+
* the shipping stages are skipped when the cart holds no physical item). */
|
|
20
|
+
export type CheckoutStage = "userDetails" | "shippingAddress" | "shippingRates" | "payment";
|
|
21
|
+
|
|
22
|
+
/** Ordered stage list — used to drive the progress indicator. */
|
|
23
|
+
export const CHECKOUT_STAGE_ORDER: CheckoutStage[] = ["userDetails", "shippingAddress", "shippingRates", "payment"];
|
|
24
|
+
|
|
25
|
+
export interface UseCheckoutOptions {
|
|
26
|
+
/** Path used to build the post-payment return URL. Defaults to `/checkout/finalise`. */
|
|
27
|
+
finalisePath?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Guest buyer identity (collected when there is no logged-in user). */
|
|
31
|
+
export interface GuestUserData {
|
|
32
|
+
firstName: string;
|
|
33
|
+
lastName: string;
|
|
34
|
+
email: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Buyer-supplied shipping address (only relevant for physical items). */
|
|
38
|
+
export interface CheckoutShippingData {
|
|
39
|
+
address1: string;
|
|
40
|
+
city: string;
|
|
41
|
+
stateCode: string;
|
|
42
|
+
countryCode: string;
|
|
43
|
+
zip: string;
|
|
44
|
+
phone: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A shipping rate the buyer has selected for a given product. */
|
|
48
|
+
export type SelectedShippingRate = CreateOrderShippingRate;
|
|
49
|
+
|
|
50
|
+
interface AppliedCoupon {
|
|
51
|
+
code: string;
|
|
52
|
+
discountType: string | null;
|
|
53
|
+
discountValue: number | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface CouponPaymentUpdate {
|
|
57
|
+
paymentSecret: string;
|
|
58
|
+
paymentId: string;
|
|
59
|
+
chargedAmount: number;
|
|
60
|
+
chargedCurrency: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const round2 = (n: number) => Math.round(n * 100) / 100;
|
|
64
|
+
|
|
65
|
+
/** Best-effort extraction of an API error message. */
|
|
66
|
+
function messageOf(e: unknown): string | undefined {
|
|
67
|
+
return (e as ApiError)?.response?.data?.message;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Headless cart-checkout behavior — the complete staged flow the client app
|
|
72
|
+
* ships, composed from the existing data hooks:
|
|
73
|
+
*
|
|
74
|
+
* guest details (guest only) → shipping address + per-product rate selection
|
|
75
|
+
* (physical only) → create order + start payment → coupon apply/remove →
|
|
76
|
+
* redirect payment (or a free-order shortcut).
|
|
77
|
+
*
|
|
78
|
+
* The order is created lazily on entering the payment stage (via
|
|
79
|
+
* `useCreateOrder` + `usePaymentFlow`), and a coupon is applied against that
|
|
80
|
+
* created order (`useApplyCoupon` re-issues the payment intent). Shipping cost
|
|
81
|
+
* is owned by the rate selector (which sums multi-currency rates into the
|
|
82
|
+
* display currency) and fed back via `setSelectedShipping` — it is never
|
|
83
|
+
* overwritten from the raw order response, which keeps the order-summary total
|
|
84
|
+
* in step with the rate the buyer picked.
|
|
85
|
+
*
|
|
86
|
+
* This hook holds state + async transitions only; formatting, navigation and
|
|
87
|
+
* the payment UI live in the consuming component (`<Checkout />`).
|
|
88
|
+
*/
|
|
89
|
+
export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
90
|
+
const finalisePath = opts.finalisePath ?? "/checkout/finalise";
|
|
91
|
+
const { user } = usePublicAuth();
|
|
92
|
+
const { cartItems, isReady: isCartReady } = useCart();
|
|
93
|
+
const { data: countries = [] } = useShippingCountries();
|
|
94
|
+
const shippingRates = useShippingRates();
|
|
95
|
+
const createOrder = useCreateOrder();
|
|
96
|
+
const applyCouponMutation = useApplyCoupon();
|
|
97
|
+
|
|
98
|
+
const hasPhysicalProduct = useMemo(
|
|
99
|
+
() => cartItems.some((item) => item.deliveryType === ProductDeliveryType.Physical),
|
|
100
|
+
[cartItems],
|
|
101
|
+
);
|
|
102
|
+
const subTotal = useMemo(
|
|
103
|
+
() => round2(cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0)),
|
|
104
|
+
[cartItems],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const [currentStage, setCurrentStage] = useState<CheckoutStage>("userDetails");
|
|
108
|
+
const [guestUserData, setGuestUserData] = useState<GuestUserData | null>(null);
|
|
109
|
+
const [shippingData, setShippingData] = useState<CheckoutShippingData>();
|
|
110
|
+
const [joinEmailList, setJoinEmailList] = useState(false);
|
|
111
|
+
const [pageError, setPageError] = useState("");
|
|
112
|
+
|
|
113
|
+
// Shipping groups from the last rate fetch + the buyer's per-group selection
|
|
114
|
+
// (flat/external auto-selected; the dynamic group is the buyer's choice).
|
|
115
|
+
const [shippingGroups, setShippingGroups] = useState<ShippingGroup[]>([]);
|
|
116
|
+
const [selections, setSelections] = useState<Record<string, SelectedShippingRate>>({});
|
|
117
|
+
const selectedShippingRates = useMemo<SelectedShippingRate[]>(() => Object.values(selections), [selections]);
|
|
118
|
+
const shippingCost = useMemo(
|
|
119
|
+
() => round2(selectedShippingRates.reduce((sum, r) => sum + r.amount, 0)),
|
|
120
|
+
[selectedShippingRates],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const [couponCode, setCouponCode] = useState("");
|
|
124
|
+
const [showCouponInput, setShowCouponInput] = useState(false);
|
|
125
|
+
const [couponError, setCouponError] = useState("");
|
|
126
|
+
const [isApplyingCoupon, setIsApplyingCoupon] = useState(false);
|
|
127
|
+
const [appliedCoupon, setAppliedCoupon] = useState<AppliedCoupon | null>(null);
|
|
128
|
+
const [discountAmount, setDiscountAmount] = useState(0);
|
|
129
|
+
const [couponPaymentUpdate, setCouponPaymentUpdate] = useState<CouponPaymentUpdate | null>(null);
|
|
130
|
+
const [chargedTotal, setChargedTotal] = useState<{ amount: number; currency: string } | null>(null);
|
|
131
|
+
// Authoritative sales-tax quote for the current payment intent. A coupon
|
|
132
|
+
// re-issues the intent (and re-quotes tax), so its quote supersedes the
|
|
133
|
+
// start-payment one. Display only — the charged amount already reflects it.
|
|
134
|
+
const [couponTaxQuote, setCouponTaxQuote] = useState<PublicTaxQuote | null>(null);
|
|
135
|
+
|
|
136
|
+
const [isFreeCheckoutLoading, setIsFreeCheckoutLoading] = useState(false);
|
|
137
|
+
const [created, setCreated] = useState<CreateOrderResult | null>(null);
|
|
138
|
+
const [startError, setStartError] = useState("");
|
|
139
|
+
const startedRef = useRef(false);
|
|
140
|
+
|
|
141
|
+
const isPaidCheckout = subTotal - discountAmount + shippingCost > 0;
|
|
142
|
+
|
|
143
|
+
const selectedCountry: ShippingCountry | undefined = useMemo(
|
|
144
|
+
() => countries.find((cc) => cc.code === shippingData?.countryCode),
|
|
145
|
+
[countries, shippingData?.countryCode],
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Pick the entry stage from the buyer + cart: a logged-in buyer skips the
|
|
149
|
+
// guest-details stage; a cart with no physical item skips shipping entirely.
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (user) {
|
|
152
|
+
setCurrentStage(hasPhysicalProduct ? "shippingAddress" : "payment");
|
|
153
|
+
} else {
|
|
154
|
+
setCurrentStage("userDetails");
|
|
155
|
+
}
|
|
156
|
+
}, [user, hasPhysicalProduct]);
|
|
157
|
+
|
|
158
|
+
// ── Order creation + payment start (lazy: only on the payment stage) ─────────
|
|
159
|
+
// Create the order once we land on the payment stage for a paid checkout, then
|
|
160
|
+
// start the payment (usePaymentFlow) against it. Guarded so it fires once.
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (currentStage !== "payment" || !isPaidCheckout || startedRef.current) return;
|
|
163
|
+
startedRef.current = true;
|
|
164
|
+
createOrder
|
|
165
|
+
.mutateAsync({
|
|
166
|
+
amount: subTotal,
|
|
167
|
+
email: guestUserData?.email || user?.email || "",
|
|
168
|
+
firstName: guestUserData?.firstName || user?.firstName,
|
|
169
|
+
lastName: guestUserData?.lastName || user?.lastName,
|
|
170
|
+
accountId: user?.id,
|
|
171
|
+
cartItems,
|
|
172
|
+
shippingAddress: shippingData,
|
|
173
|
+
selectedShippingRates: selectedShippingRates.length ? selectedShippingRates : undefined,
|
|
174
|
+
joinEmailList: joinEmailList || undefined,
|
|
175
|
+
// Last-touch attribution: the most recent tracked-link click (≤30 days)
|
|
176
|
+
// that landed this buyer gets credit for the order.
|
|
177
|
+
attributionRefId: readAttributionRef() ?? undefined,
|
|
178
|
+
// First-touch landing snapshot (all utm/click-id params + referrer).
|
|
179
|
+
...(readLanding() ?? {}),
|
|
180
|
+
})
|
|
181
|
+
.then(setCreated)
|
|
182
|
+
.catch((e) => setStartError(messageOf(e) || "An error occurred"));
|
|
183
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
184
|
+
}, [currentStage, isPaidCheckout]);
|
|
185
|
+
|
|
186
|
+
const returnUrl = created
|
|
187
|
+
? `${typeof window !== "undefined" ? window.location.origin : ""}${finalisePath}?orderId=${created.orderId}`
|
|
188
|
+
: "";
|
|
189
|
+
const flow = usePaymentFlow({
|
|
190
|
+
path: "/public/orders/start-payment",
|
|
191
|
+
body: { orderId: created?.orderId },
|
|
192
|
+
returnUrl,
|
|
193
|
+
enabled: !!created?.orderId,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const orderId = created?.orderId ?? null;
|
|
197
|
+
|
|
198
|
+
// A coupon re-issues the payment intent, so its update supersedes the initial
|
|
199
|
+
// start-payment result. Otherwise use the started payment once both the order
|
|
200
|
+
// and start-payment have resolved.
|
|
201
|
+
const effectivePayment = useMemo<CouponPaymentUpdate | null>(() => {
|
|
202
|
+
if (couponPaymentUpdate) return couponPaymentUpdate;
|
|
203
|
+
if (created && flow.result?.paymentSecret && flow.result?.paymentId) {
|
|
204
|
+
return {
|
|
205
|
+
paymentSecret: flow.result.paymentSecret,
|
|
206
|
+
paymentId: flow.result.paymentId,
|
|
207
|
+
chargedAmount: flow.result.amount ?? 0,
|
|
208
|
+
chargedCurrency: flow.result.currency ?? "",
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}, [created, flow.result, couponPaymentUpdate]);
|
|
213
|
+
|
|
214
|
+
// Surface the charged total (for the summary) as soon as a payment resolves.
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
if (effectivePayment) setChargedTotal({ amount: effectivePayment.chargedAmount, currency: effectivePayment.chargedCurrency });
|
|
217
|
+
}, [effectivePayment]);
|
|
218
|
+
|
|
219
|
+
// The tax quote for the summary: the coupon re-quote wins over the initial
|
|
220
|
+
// start-payment quote (same supersede rule as the payment intent itself).
|
|
221
|
+
const taxQuote: PublicTaxQuote | null = couponTaxQuote ?? flow.result?.taxQuote ?? null;
|
|
222
|
+
|
|
223
|
+
// ── Stage transitions ───────────────────────────────────────────────────────
|
|
224
|
+
const submitGuest = useCallback(
|
|
225
|
+
(data: GuestUserData) => {
|
|
226
|
+
setGuestUserData(data);
|
|
227
|
+
setCurrentStage(hasPhysicalProduct ? "shippingAddress" : "payment");
|
|
228
|
+
},
|
|
229
|
+
[hasPhysicalProduct],
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const submitAddress = useCallback(
|
|
233
|
+
async (data: CheckoutShippingData) => {
|
|
234
|
+
setShippingData(data);
|
|
235
|
+
setPageError("");
|
|
236
|
+
try {
|
|
237
|
+
const ratesData = await shippingRates.mutateAsync({
|
|
238
|
+
shippingAddress: data,
|
|
239
|
+
cartItems: cartItems.map((item) => ({
|
|
240
|
+
productId: item.productId,
|
|
241
|
+
productVariantId: item.productVariantId,
|
|
242
|
+
quantity: item.quantity,
|
|
243
|
+
})),
|
|
244
|
+
});
|
|
245
|
+
setShippingGroups(ratesData.groups);
|
|
246
|
+
// Auto-select the fixed groups (flat / external); the buyer chooses the
|
|
247
|
+
// dynamic (carrier) group.
|
|
248
|
+
const auto: Record<string, SelectedShippingRate> = {};
|
|
249
|
+
for (const g of ratesData.groups) {
|
|
250
|
+
if (!g.selectable && g.rates.length > 0) auto[g.id] = toSelected(g, g.rates[0]);
|
|
251
|
+
}
|
|
252
|
+
setSelections(auto);
|
|
253
|
+
setCurrentStage("shippingRates");
|
|
254
|
+
} catch (e) {
|
|
255
|
+
setPageError(messageOf(e) || "Failed to fetch shipping rates. Please check your address and try again.");
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
[cartItems, shippingRates],
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
/** Pick a rate for one shipping group (the dynamic/carrier group). */
|
|
262
|
+
const selectShippingRate = useCallback((group: ShippingGroup, rate: ShippingRate) => {
|
|
263
|
+
setSelections((prev) => ({ ...prev, [group.id]: toSelected(group, rate) }));
|
|
264
|
+
}, []);
|
|
265
|
+
|
|
266
|
+
const confirmRates = useCallback(() => {
|
|
267
|
+
// Every group the buyer must choose (selectable = dynamic) needs a selection.
|
|
268
|
+
const missing = shippingGroups.some((g) => g.selectable && !selections[g.id]);
|
|
269
|
+
if (missing) {
|
|
270
|
+
setPageError("Please select a shipping option before continuing.");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
setPageError("");
|
|
274
|
+
setCurrentStage("payment");
|
|
275
|
+
}, [shippingGroups, selections]);
|
|
276
|
+
|
|
277
|
+
// ── Coupon (applied against the created order) ──────────────────────────────
|
|
278
|
+
const applyCoupon = useCallback(async () => {
|
|
279
|
+
if (!couponCode.trim() || !orderId) return;
|
|
280
|
+
setCouponError("");
|
|
281
|
+
setIsApplyingCoupon(true);
|
|
282
|
+
try {
|
|
283
|
+
const ru = `${window.location.origin}${finalisePath}?orderId=${orderId}`;
|
|
284
|
+
const data = await applyCouponMutation.mutateAsync({ orderId, couponCode: couponCode.trim(), returnUrl: ru });
|
|
285
|
+
setAppliedCoupon(data.coupon);
|
|
286
|
+
setDiscountAmount(data.discountAmount);
|
|
287
|
+
setChargedTotal({ amount: data.chargedAmount, currency: data.chargedCurrency });
|
|
288
|
+
setCouponTaxQuote(data.taxQuote ?? null);
|
|
289
|
+
setCouponPaymentUpdate(
|
|
290
|
+
data.paymentSecret && data.paymentId
|
|
291
|
+
? {
|
|
292
|
+
paymentSecret: data.paymentSecret,
|
|
293
|
+
paymentId: data.paymentId,
|
|
294
|
+
chargedAmount: data.chargedAmount,
|
|
295
|
+
chargedCurrency: data.chargedCurrency,
|
|
296
|
+
}
|
|
297
|
+
: null,
|
|
298
|
+
);
|
|
299
|
+
} catch (e) {
|
|
300
|
+
setCouponError(messageOf(e) || "Invalid coupon code");
|
|
301
|
+
} finally {
|
|
302
|
+
setIsApplyingCoupon(false);
|
|
303
|
+
}
|
|
304
|
+
}, [couponCode, orderId, finalisePath, applyCouponMutation]);
|
|
305
|
+
|
|
306
|
+
const removeCoupon = useCallback(async () => {
|
|
307
|
+
if (!orderId) return;
|
|
308
|
+
setCouponError("");
|
|
309
|
+
setIsApplyingCoupon(true);
|
|
310
|
+
try {
|
|
311
|
+
const ru = `${window.location.origin}${finalisePath}?orderId=${orderId}`;
|
|
312
|
+
const data = await applyCouponMutation.mutateAsync({ orderId, returnUrl: ru });
|
|
313
|
+
setAppliedCoupon(null);
|
|
314
|
+
setCouponCode("");
|
|
315
|
+
setShowCouponInput(false);
|
|
316
|
+
setDiscountAmount(0);
|
|
317
|
+
setChargedTotal({ amount: data.chargedAmount, currency: data.chargedCurrency });
|
|
318
|
+
setCouponTaxQuote(data.taxQuote ?? null);
|
|
319
|
+
if (data.paymentSecret && data.paymentId) {
|
|
320
|
+
setCouponPaymentUpdate({
|
|
321
|
+
paymentSecret: data.paymentSecret,
|
|
322
|
+
paymentId: data.paymentId,
|
|
323
|
+
chargedAmount: data.chargedAmount,
|
|
324
|
+
chargedCurrency: data.chargedCurrency,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
} catch (e) {
|
|
328
|
+
setCouponError(messageOf(e) || "Failed to remove coupon");
|
|
329
|
+
} finally {
|
|
330
|
+
setIsApplyingCoupon(false);
|
|
331
|
+
}
|
|
332
|
+
}, [orderId, finalisePath, applyCouponMutation]);
|
|
333
|
+
|
|
334
|
+
// ── Free checkout (nothing to charge) ───────────────────────────────────────
|
|
335
|
+
/** Create the (free) order and return its id so the caller can navigate to the
|
|
336
|
+
* finalise page. Returns null on failure (with `pageError` set). */
|
|
337
|
+
const freeCheckout = useCallback(async (): Promise<string | null> => {
|
|
338
|
+
setIsFreeCheckoutLoading(true);
|
|
339
|
+
setPageError("");
|
|
340
|
+
try {
|
|
341
|
+
const data = await createOrder.mutateAsync({
|
|
342
|
+
amount: subTotal,
|
|
343
|
+
email: guestUserData?.email || user?.email || "",
|
|
344
|
+
firstName: guestUserData?.firstName || user?.firstName || "",
|
|
345
|
+
lastName: guestUserData?.lastName || user?.lastName || "",
|
|
346
|
+
accountId: user?.id,
|
|
347
|
+
cartItems,
|
|
348
|
+
shippingAddress: shippingData,
|
|
349
|
+
selectedShippingRates: selectedShippingRates.length ? selectedShippingRates : undefined,
|
|
350
|
+
couponCode: appliedCoupon?.code,
|
|
351
|
+
joinEmailList: joinEmailList || undefined,
|
|
352
|
+
});
|
|
353
|
+
return data.orderId;
|
|
354
|
+
} catch (e) {
|
|
355
|
+
setPageError(messageOf(e) || "An error occurred");
|
|
356
|
+
return null;
|
|
357
|
+
} finally {
|
|
358
|
+
setIsFreeCheckoutLoading(false);
|
|
359
|
+
}
|
|
360
|
+
}, [subTotal, guestUserData, user, cartItems, shippingData, selectedShippingRates, appliedCoupon, joinEmailList, createOrder]);
|
|
361
|
+
|
|
362
|
+
const provider = flow.provider;
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
// ── Cart + totals ────────────────────────────────────────────────────────
|
|
366
|
+
cartItems,
|
|
367
|
+
isCartReady,
|
|
368
|
+
subTotal,
|
|
369
|
+
shippingCost,
|
|
370
|
+
discountAmount,
|
|
371
|
+
hasPhysicalProduct,
|
|
372
|
+
isPaidCheckout,
|
|
373
|
+
chargedTotal,
|
|
374
|
+
taxQuote,
|
|
375
|
+
// ── Stage machine ──────────────────────────────────────────────────────────
|
|
376
|
+
currentStage,
|
|
377
|
+
setCurrentStage,
|
|
378
|
+
// ── Buyer ──────────────────────────────────────────────────────────────────
|
|
379
|
+
user,
|
|
380
|
+
guestUserData,
|
|
381
|
+
joinEmailList,
|
|
382
|
+
setJoinEmailList,
|
|
383
|
+
submitGuest,
|
|
384
|
+
// ── Shipping ───────────────────────────────────────────────────────────────
|
|
385
|
+
countries,
|
|
386
|
+
selectedCountry,
|
|
387
|
+
shippingData,
|
|
388
|
+
submitAddress,
|
|
389
|
+
isFetchingRates: shippingRates.isPending,
|
|
390
|
+
shippingGroups,
|
|
391
|
+
selections,
|
|
392
|
+
selectShippingRate,
|
|
393
|
+
selectedShippingRates,
|
|
394
|
+
confirmRates,
|
|
395
|
+
// ── Order + payment ────────────────────────────────────────────────────────
|
|
396
|
+
orderId,
|
|
397
|
+
isCreatingOrder: createOrder.isPending || flow.isStarting,
|
|
398
|
+
startError,
|
|
399
|
+
provider,
|
|
400
|
+
isPaystack: provider === PaymentProviderName.Paystack,
|
|
401
|
+
paymentSecret: effectivePayment?.paymentSecret,
|
|
402
|
+
paymentId: effectivePayment?.paymentId,
|
|
403
|
+
chargedAmount: effectivePayment?.chargedAmount ?? 0,
|
|
404
|
+
chargedCurrency: effectivePayment?.chargedCurrency ?? "",
|
|
405
|
+
returnUrl: `${typeof window !== "undefined" ? window.location.origin : ""}${finalisePath}?orderId=${orderId || ""}`,
|
|
406
|
+
// ── Free checkout ──────────────────────────────────────────────────────────
|
|
407
|
+
isFreeCheckoutLoading,
|
|
408
|
+
freeCheckout,
|
|
409
|
+
// ── Coupon ─────────────────────────────────────────────────────────────────
|
|
410
|
+
couponCode,
|
|
411
|
+
setCouponCode,
|
|
412
|
+
showCouponInput,
|
|
413
|
+
setShowCouponInput,
|
|
414
|
+
appliedCoupon,
|
|
415
|
+
couponError,
|
|
416
|
+
isApplyingCoupon,
|
|
417
|
+
applyCoupon,
|
|
418
|
+
removeCoupon,
|
|
419
|
+
// ── Errors ─────────────────────────────────────────────────────────────────
|
|
420
|
+
pageError,
|
|
421
|
+
setPageError,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Narrow a group + chosen rate into the order-selection payload. */
|
|
426
|
+
function toSelected(group: ShippingGroup, rate: ShippingRate): SelectedShippingRate {
|
|
427
|
+
return {
|
|
428
|
+
groupId: group.id,
|
|
429
|
+
type: group.type,
|
|
430
|
+
productIds: group.productIds,
|
|
431
|
+
rateId: rate.rateId,
|
|
432
|
+
shipmentId: rate.shipmentId,
|
|
433
|
+
amount: rate.amount,
|
|
434
|
+
currency: rate.currency,
|
|
435
|
+
carrier: rate.carrier,
|
|
436
|
+
service: rate.service,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
3
|
+
import { useGetCoachingProduct } from "../../../data/queries/useCoachingProducts";
|
|
4
|
+
import {
|
|
5
|
+
useCoachingAvailability,
|
|
6
|
+
useReserveCoachingBooking,
|
|
7
|
+
useUpdateCoachingBooking,
|
|
8
|
+
useUnreserveCoachingBooking,
|
|
9
|
+
} from "../../../data/queries/useCoachingAvailability";
|
|
10
|
+
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
11
|
+
import { readAttributionRef } from "../../../utils/attribution";
|
|
12
|
+
import { readLanding } from "../../../utils/landing";
|
|
13
|
+
|
|
14
|
+
export type CoachingBookingStep = "slot" | "details" | "payment";
|
|
15
|
+
|
|
16
|
+
/** How long a reserved slot is held before it auto-releases (client-side, matches the client app). */
|
|
17
|
+
export const RESERVATION_HOLD_MS = 15 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
export interface UseCoachingBookingOptions {
|
|
20
|
+
/** Stripe return URL for a PAID booking (its `return_url`). Default
|
|
21
|
+
* `/i/coaching/:slug/finalise`. A real URL is required for the redirect leg. */
|
|
22
|
+
finalisePath?: (slug: string, bookingId: string) => string;
|
|
23
|
+
/** Called when a FREE booking completes in-app — the host navigates however it
|
|
24
|
+
* wants, instead of Forge doing a `window.location` redirect. */
|
|
25
|
+
onComplete?: (info: { slug: string; bookingId: string }) => void;
|
|
26
|
+
/**
|
|
27
|
+
* Fix the availability window (ISO) and disable the built-in week navigation.
|
|
28
|
+
* Leave unset to browse availability week-by-week (the default, matches the client app).
|
|
29
|
+
*/
|
|
30
|
+
fromDate?: string;
|
|
31
|
+
toDate?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const errMessage = (e: unknown) =>
|
|
35
|
+
(e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Something went wrong.";
|
|
36
|
+
|
|
37
|
+
// Sunday-anchored start of the week for `d`, at 00:00 local.
|
|
38
|
+
const startOfWeekSunday = (d: Date) => {
|
|
39
|
+
const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
40
|
+
x.setDate(x.getDate() - x.getDay());
|
|
41
|
+
return x;
|
|
42
|
+
};
|
|
43
|
+
const addDays = (d: Date, n: number) => {
|
|
44
|
+
const x = new Date(d);
|
|
45
|
+
x.setDate(x.getDate() + n);
|
|
46
|
+
return x;
|
|
47
|
+
};
|
|
48
|
+
const endOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Headless coaching booking: slot selection (reserve) → buyer details (update,
|
|
52
|
+
* confirms free bookings) → payment. Composes `useGetCoachingProduct`,
|
|
53
|
+
* `useCoachingAvailability`, the reserve/update/unreserve mutations and
|
|
54
|
+
* `usePaymentFlow`. Finalize via `useCoachingBookingFinalize`.
|
|
55
|
+
*/
|
|
56
|
+
export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOptions = {}) {
|
|
57
|
+
const { user } = usePublicAuth();
|
|
58
|
+
// Detail by slug; availability + payment are scoped by the real product id
|
|
59
|
+
// (availability slots are filtered by the product's id).
|
|
60
|
+
const { data: product, isLoading } = useGetCoachingProduct(slug);
|
|
61
|
+
const productId = product?.id;
|
|
62
|
+
|
|
63
|
+
// Week navigation: browse availability one Sunday–Saturday week at a time,
|
|
64
|
+
// unless a fixed window was passed via opts. `currentWeekStart` is anchored at
|
|
65
|
+
// mount so "this week" doesn't drift while the modal is open.
|
|
66
|
+
const [currentWeekStart] = useState(() => startOfWeekSunday(new Date()));
|
|
67
|
+
const [weekStart, setWeekStart] = useState<Date>(currentWeekStart);
|
|
68
|
+
const weekEnd = addDays(weekStart, 6);
|
|
69
|
+
const canGoPrevWeek = weekStart.getTime() > currentWeekStart.getTime();
|
|
70
|
+
const goPrevWeek = () => setWeekStart((w) => (w.getTime() > currentWeekStart.getTime() ? addDays(w, -7) : w));
|
|
71
|
+
const goNextWeek = () => setWeekStart((w) => addDays(w, 7));
|
|
72
|
+
|
|
73
|
+
const windowFrom = opts.fromDate ?? weekStart.toISOString();
|
|
74
|
+
const windowTo = opts.toDate ?? endOfDay(weekEnd).toISOString();
|
|
75
|
+
const availability = useCoachingAvailability(productId, windowFrom, windowTo);
|
|
76
|
+
const reserve = useReserveCoachingBooking(productId);
|
|
77
|
+
const update = useUpdateCoachingBooking(productId);
|
|
78
|
+
const unreserve = useUnreserveCoachingBooking(productId);
|
|
79
|
+
const flow = usePaymentFlow({ path: `/public/coaching/products/${productId}/booking/start-payment`, autoStart: false });
|
|
80
|
+
|
|
81
|
+
const [step, setStep] = useState<CoachingBookingStep>("slot");
|
|
82
|
+
const [bookingId, setBookingId] = useState<string | null>(null);
|
|
83
|
+
const [selectedSlotId, setSelectedSlotId] = useState<string | null>(null);
|
|
84
|
+
const [firstName, setFirstName] = useState(user?.firstName ?? "");
|
|
85
|
+
const [lastName, setLastName] = useState(user?.lastName ?? "");
|
|
86
|
+
const [email, setEmail] = useState(user?.email ?? "");
|
|
87
|
+
const [questionnaire, setQuestionnaire] = useState<unknown>(undefined);
|
|
88
|
+
const [returnUrl, setReturnUrl] = useState("");
|
|
89
|
+
const [error, setError] = useState<string | null>(null);
|
|
90
|
+
// Epoch ms when the current slot hold expires (null when nothing is held).
|
|
91
|
+
const [reservationExpiresAt, setReservationExpiresAt] = useState<number | null>(null);
|
|
92
|
+
|
|
93
|
+
const finalisePath =
|
|
94
|
+
opts.finalisePath ?? ((s, bId) => `/i/coaching/${s}/finalise?orderId=${bId}`);
|
|
95
|
+
|
|
96
|
+
// Highlight a slot (no server hold yet — the slot is reserved on "Continue").
|
|
97
|
+
const selectSlot = (slotId: string) => {
|
|
98
|
+
setError(null);
|
|
99
|
+
setSelectedSlotId(slotId);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const changeWeek = (dir: -1 | 1) => {
|
|
103
|
+
setSelectedSlotId(null);
|
|
104
|
+
if (dir === -1) goPrevWeek();
|
|
105
|
+
else goNextWeek();
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Reserve the highlighted slot and advance to buyer details. On failure
|
|
110
|
+
* (e.g. the slot was just taken) it refetches availability and clears the
|
|
111
|
+
* selection so the user can pick another.
|
|
112
|
+
*/
|
|
113
|
+
const reserveSelected = async () => {
|
|
114
|
+
setError(null);
|
|
115
|
+
if (!selectedSlotId) {
|
|
116
|
+
setError("Please select an available time slot.");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
// Release any previously-held slot before reserving a new one.
|
|
121
|
+
if (bookingId) await unreserve.mutateAsync({ bookingId }).catch(() => undefined);
|
|
122
|
+
const data = await reserve.mutateAsync({ slotId: selectedSlotId });
|
|
123
|
+
setBookingId(data.bookingId);
|
|
124
|
+
setReservationExpiresAt(Date.now() + RESERVATION_HOLD_MS);
|
|
125
|
+
setStep("details");
|
|
126
|
+
} catch (e) {
|
|
127
|
+
setError(errMessage(e));
|
|
128
|
+
setSelectedSlotId(null);
|
|
129
|
+
availability.refetch();
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Release the currently-held slot: unreserve the booking and clear the hold,
|
|
135
|
+
* returning to slot selection. Call this when the user leaves/closes the flow.
|
|
136
|
+
*/
|
|
137
|
+
const releaseReservation = useCallback(async () => {
|
|
138
|
+
if (bookingId) await unreserve.mutateAsync({ bookingId }).catch(() => undefined);
|
|
139
|
+
setBookingId(null);
|
|
140
|
+
setSelectedSlotId(null);
|
|
141
|
+
setReservationExpiresAt(null);
|
|
142
|
+
setStep("slot");
|
|
143
|
+
}, [bookingId, unreserve]);
|
|
144
|
+
|
|
145
|
+
/** Release the hold and surface a notice — call when the reservation timer runs out. */
|
|
146
|
+
const expireReservation = useCallback(async () => {
|
|
147
|
+
await releaseReservation();
|
|
148
|
+
setError("Your reservation expired. Please pick a new time slot.");
|
|
149
|
+
}, [releaseReservation]);
|
|
150
|
+
|
|
151
|
+
const continueToPayment = async () => {
|
|
152
|
+
setError(null);
|
|
153
|
+
if (!slug || !productId || !bookingId) return;
|
|
154
|
+
if (!firstName.trim() || !lastName.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
155
|
+
setError("Please enter your name and a valid email.");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const updated = await update.mutateAsync({
|
|
160
|
+
bookingId,
|
|
161
|
+
email,
|
|
162
|
+
firstName,
|
|
163
|
+
lastName,
|
|
164
|
+
confirmIfFree: true,
|
|
165
|
+
questionnaire,
|
|
166
|
+
attributionRefId: readAttributionRef() ?? undefined,
|
|
167
|
+
...(readLanding() ?? {}),
|
|
168
|
+
});
|
|
169
|
+
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
170
|
+
const ru = `${origin}${finalisePath(slug, bookingId)}`;
|
|
171
|
+
setReturnUrl(ru);
|
|
172
|
+
// Free bookings are confirmed by `update` — there's no payment to start
|
|
173
|
+
// (start-payment rejects a confirmed booking), so go straight to finalise.
|
|
174
|
+
if (updated.isConfirmed) {
|
|
175
|
+
if (opts.onComplete) opts.onComplete({ slug, bookingId });
|
|
176
|
+
else if (typeof window !== "undefined") window.location.href = ru;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const result = await flow.start({ bookingId, returnUrl: ru });
|
|
180
|
+
if (result.provider && result.provider !== "stripe") return;
|
|
181
|
+
setStep("payment");
|
|
182
|
+
} catch (e) {
|
|
183
|
+
setError(errMessage(e));
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
product,
|
|
189
|
+
isLoading,
|
|
190
|
+
availability,
|
|
191
|
+
step,
|
|
192
|
+
setStep,
|
|
193
|
+
selectedSlotId,
|
|
194
|
+
bookingId,
|
|
195
|
+
selectSlot,
|
|
196
|
+
reserveSelected,
|
|
197
|
+
reservationExpiresAt,
|
|
198
|
+
expireReservation,
|
|
199
|
+
releaseReservation,
|
|
200
|
+
// Week navigation
|
|
201
|
+
weekStart,
|
|
202
|
+
weekEnd,
|
|
203
|
+
canGoPrevWeek,
|
|
204
|
+
goPrevWeek: () => changeWeek(-1),
|
|
205
|
+
goNextWeek: () => changeWeek(1),
|
|
206
|
+
firstName,
|
|
207
|
+
setFirstName,
|
|
208
|
+
lastName,
|
|
209
|
+
setLastName,
|
|
210
|
+
email,
|
|
211
|
+
setEmail,
|
|
212
|
+
questionnaire,
|
|
213
|
+
setQuestionnaire,
|
|
214
|
+
continueToPayment,
|
|
215
|
+
clientSecret: flow.clientSecret,
|
|
216
|
+
totalAmount: flow.result?.totalAmount,
|
|
217
|
+
/** Authoritative sales-tax quote from start-payment (display only). */
|
|
218
|
+
taxQuote: flow.result?.taxQuote ?? null,
|
|
219
|
+
returnUrl,
|
|
220
|
+
isProcessing: reserve.isPending || update.isPending || flow.isStarting,
|
|
221
|
+
error,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Community headless primitives. NOTE: this is a lane-local aggregator — the
|
|
2
|
+
// integrator re-exports it from `ui/headless/index.ts` (`export * from "./community"`).
|
|
3
|
+
export {
|
|
4
|
+
buildLexicalState,
|
|
5
|
+
lexicalToPlainText,
|
|
6
|
+
mentionsInText,
|
|
7
|
+
type LexicalNodeJson,
|
|
8
|
+
type MentionRef,
|
|
9
|
+
} from "./lexical";
|
|
10
|
+
export { buildReplyTree, findReplyNode, type ReplyNode } from "./replyTree";
|
|
11
|
+
export {
|
|
12
|
+
useCommunityAttachmentUpload,
|
|
13
|
+
MAX_COMMUNITY_ATTACHMENT_BYTES,
|
|
14
|
+
type PendingCommunityAttachment,
|
|
15
|
+
} from "./useCommunityAttachmentUpload";
|
|
16
|
+
export { useMentionComposer, type MentionSuggestion } from "./useMentionComposer";
|
|
17
|
+
export { useRichMentionComposer, type RichCommand } from "./useRichMentionComposer";
|
|
18
|
+
export { serializeRichToLexical, collectMentions, isRichEmpty } from "./richLexical";
|
|
19
|
+
export { useCommunitySpaceFeed } from "./useCommunitySpaceFeed";
|
|
20
|
+
export { useCommunityThread } from "./useCommunityThread";
|