@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.
Files changed (255) hide show
  1. package/package.json +42 -0
  2. package/src/client/createForgeClient.ts +54 -0
  3. package/src/content/ForgeContentProvider.tsx +118 -0
  4. package/src/content/ForgeEditBridge.tsx +134 -0
  5. package/src/content/editFlag.ts +23 -0
  6. package/src/content/index.ts +28 -0
  7. package/src/content/resolve.ts +67 -0
  8. package/src/content/types.ts +97 -0
  9. package/src/content/useField.ts +64 -0
  10. package/src/contexts/AppAuthContext.tsx +185 -0
  11. package/src/contexts/AudioPlayerContext.tsx +389 -0
  12. package/src/contexts/CartContext.tsx +131 -0
  13. package/src/contexts/PublicAuthContext.tsx +333 -0
  14. package/src/contexts/useAppAdminGuard.ts +56 -0
  15. package/src/css.d.ts +5 -0
  16. package/src/data/collectionParams.ts +57 -0
  17. package/src/data/queries/useAccount.ts +71 -0
  18. package/src/data/queries/useAccountSettings.ts +82 -0
  19. package/src/data/queries/useAnalytics.ts +86 -0
  20. package/src/data/queries/useAuthActions.ts +28 -0
  21. package/src/data/queries/useBlog.ts +103 -0
  22. package/src/data/queries/useBlogComments.ts +96 -0
  23. package/src/data/queries/useBroadcasts.ts +31 -0
  24. package/src/data/queries/useCertificates.ts +58 -0
  25. package/src/data/queries/useChat.ts +267 -0
  26. package/src/data/queries/useCoachingAvailability.ts +69 -0
  27. package/src/data/queries/useCoachingProducts.ts +67 -0
  28. package/src/data/queries/useCohortPage.ts +17 -0
  29. package/src/data/queries/useCollections.ts +211 -0
  30. package/src/data/queries/useCommunity.ts +794 -0
  31. package/src/data/queries/useCoupons.ts +20 -0
  32. package/src/data/queries/useCourseAccess.ts +50 -0
  33. package/src/data/queries/useCourses.ts +91 -0
  34. package/src/data/queries/useCurrencies.ts +33 -0
  35. package/src/data/queries/useDocuments.ts +208 -0
  36. package/src/data/queries/useDonations.ts +29 -0
  37. package/src/data/queries/useEngagement.ts +115 -0
  38. package/src/data/queries/useEvents.ts +69 -0
  39. package/src/data/queries/useFinalize.ts +109 -0
  40. package/src/data/queries/useForms.ts +31 -0
  41. package/src/data/queries/useInvoice.ts +65 -0
  42. package/src/data/queries/useLeadMagnet.ts +96 -0
  43. package/src/data/queries/useMembership.ts +20 -0
  44. package/src/data/queries/useNotifications.ts +267 -0
  45. package/src/data/queries/useOffers.ts +56 -0
  46. package/src/data/queries/useOrders.ts +71 -0
  47. package/src/data/queries/usePWA.ts +101 -0
  48. package/src/data/queries/usePageActions.ts +36 -0
  49. package/src/data/queries/usePaymentFlow.ts +80 -0
  50. package/src/data/queries/usePaymentLink.ts +47 -0
  51. package/src/data/queries/usePodcast.ts +85 -0
  52. package/src/data/queries/usePostCollections.ts +48 -0
  53. package/src/data/queries/usePosts.ts +94 -0
  54. package/src/data/queries/useProducts.ts +108 -0
  55. package/src/data/queries/useReplay.ts +19 -0
  56. package/src/data/queries/useReviews.ts +122 -0
  57. package/src/data/queries/useShipping.ts +109 -0
  58. package/src/data/queries/useSubscriptions.ts +66 -0
  59. package/src/data/queries/useWebsite.ts +128 -0
  60. package/src/data/queries/useWebsiteAgent.ts +100 -0
  61. package/src/index.ts +98 -0
  62. package/src/provider/ForgeAppProvider.tsx +149 -0
  63. package/src/provider/ForgeProvider.tsx +91 -0
  64. package/src/provider/SiteConfigProvider.tsx +27 -0
  65. package/src/server/_tests/buildPwaHead.spec.ts +182 -0
  66. package/src/server/_tests/buildWebManifest.spec.ts +313 -0
  67. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
  68. package/src/server/index.ts +260 -0
  69. package/src/server/jobs.ts +104 -0
  70. package/src/server/pwa.ts +332 -0
  71. package/src/types/index.ts +74 -0
  72. package/src/types/models.ts +1313 -0
  73. package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
  74. package/src/ui/analytics/PageMetaPixel.tsx +92 -0
  75. package/src/ui/content/Editable.tsx +90 -0
  76. package/src/ui/format/PriceDisplay.tsx +142 -0
  77. package/src/ui/format/useFormatCurrency.ts +92 -0
  78. package/src/ui/headless/agent/useAiAgent.ts +106 -0
  79. package/src/ui/headless/auth/useLoginFlow.ts +72 -0
  80. package/src/ui/headless/auth/useSignupForm.ts +85 -0
  81. package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
  82. package/src/ui/headless/chat/attachmentKind.ts +27 -0
  83. package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
  84. package/src/ui/headless/chat/useChatRoom.ts +223 -0
  85. package/src/ui/headless/chat/useMessageThread.ts +60 -0
  86. package/src/ui/headless/checkout/useCheckout.ts +438 -0
  87. package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
  88. package/src/ui/headless/community/index.ts +20 -0
  89. package/src/ui/headless/community/lexical.ts +131 -0
  90. package/src/ui/headless/community/replyTree.ts +51 -0
  91. package/src/ui/headless/community/richLexical.ts +314 -0
  92. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
  93. package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
  94. package/src/ui/headless/community/useCommunityThread.ts +95 -0
  95. package/src/ui/headless/community/useMentionComposer.ts +105 -0
  96. package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
  97. package/src/ui/headless/consent/useCookieConsent.ts +81 -0
  98. package/src/ui/headless/course/useCourseCheckout.ts +99 -0
  99. package/src/ui/headless/dialog.tsx +14 -0
  100. package/src/ui/headless/document/index.ts +7 -0
  101. package/src/ui/headless/document/useContractSignature.ts +68 -0
  102. package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
  103. package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
  104. package/src/ui/headless/document/useSignaturePad.ts +180 -0
  105. package/src/ui/headless/donation/Donation.tsx +115 -0
  106. package/src/ui/headless/donation/DonationContext.tsx +223 -0
  107. package/src/ui/headless/donation/fees.ts +15 -0
  108. package/src/ui/headless/donation/index.ts +10 -0
  109. package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
  110. package/src/ui/headless/event/useEventCheckout.ts +141 -0
  111. package/src/ui/headless/forms/useContactForm.ts +58 -0
  112. package/src/ui/headless/forms/useEmailListForm.ts +75 -0
  113. package/src/ui/headless/forms/useSectionedForm.ts +256 -0
  114. package/src/ui/headless/index.ts +60 -0
  115. package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
  116. package/src/ui/headless/memberHome/index.ts +7 -0
  117. package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
  118. package/src/ui/headless/membership/MembershipGate.tsx +39 -0
  119. package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
  120. package/src/ui/headless/offer/Offer.tsx +88 -0
  121. package/src/ui/headless/offer/OfferContext.tsx +162 -0
  122. package/src/ui/headless/offer/index.ts +8 -0
  123. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
  124. package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
  125. package/src/ui/headless/posts/index.ts +13 -0
  126. package/src/ui/headless/posts/upsell.ts +41 -0
  127. package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
  128. package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
  129. package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
  130. package/src/ui/headless/posts/usePostItem.ts +81 -0
  131. package/src/ui/headless/reviews/index.ts +6 -0
  132. package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
  133. package/src/ui/headless/reviews/useReviewForm.ts +108 -0
  134. package/src/ui/headless/reviews/useReviewsList.ts +79 -0
  135. package/src/ui/headless/work/index.ts +25 -0
  136. package/src/ui/headless/work/useWorkPortal.ts +349 -0
  137. package/src/ui/index.ts +144 -0
  138. package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
  139. package/src/ui/payment/ForgeStripePayment.tsx +171 -0
  140. package/src/ui/shell/TribeNestApp.tsx +121 -0
  141. package/src/ui/shell/shellGating.spec.ts +28 -0
  142. package/src/ui/shell/shellGating.ts +11 -0
  143. package/src/ui/styled/AccountDashboard.tsx +575 -0
  144. package/src/ui/styled/AiAgentWidget.tsx +152 -0
  145. package/src/ui/styled/AudioPlayer.tsx +123 -0
  146. package/src/ui/styled/BlogCategory.tsx +79 -0
  147. package/src/ui/styled/BlogComments.tsx +236 -0
  148. package/src/ui/styled/BlogList.tsx +101 -0
  149. package/src/ui/styled/BlogPost.tsx +160 -0
  150. package/src/ui/styled/BlogPostFooter.tsx +33 -0
  151. package/src/ui/styled/BotProtection.tsx +101 -0
  152. package/src/ui/styled/Button.tsx +88 -0
  153. package/src/ui/styled/Cart.tsx +276 -0
  154. package/src/ui/styled/ChatRoom.tsx +252 -0
  155. package/src/ui/styled/Checkout.tsx +988 -0
  156. package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
  157. package/src/ui/styled/CoachingBooking.tsx +605 -0
  158. package/src/ui/styled/CoachingConfirmation.tsx +151 -0
  159. package/src/ui/styled/CoachingDetail.tsx +99 -0
  160. package/src/ui/styled/CohortPage.tsx +82 -0
  161. package/src/ui/styled/ConfirmSubscription.tsx +134 -0
  162. package/src/ui/styled/Confirmation.tsx +171 -0
  163. package/src/ui/styled/ContactForm.tsx +81 -0
  164. package/src/ui/styled/CookieConsent.tsx +239 -0
  165. package/src/ui/styled/CourseAccess.tsx +169 -0
  166. package/src/ui/styled/CourseCheckout.tsx +425 -0
  167. package/src/ui/styled/CourseConfirmation.tsx +191 -0
  168. package/src/ui/styled/CourseDetail.tsx +140 -0
  169. package/src/ui/styled/CoursesGrid.tsx +85 -0
  170. package/src/ui/styled/CurrencySwitcher.tsx +51 -0
  171. package/src/ui/styled/DonationButton.tsx +247 -0
  172. package/src/ui/styled/DonationPage.tsx +29 -0
  173. package/src/ui/styled/EmailListForm.tsx +88 -0
  174. package/src/ui/styled/EventConfirmation.tsx +228 -0
  175. package/src/ui/styled/EventCountdown.tsx +98 -0
  176. package/src/ui/styled/EventDetail.tsx +141 -0
  177. package/src/ui/styled/EventTickets.tsx +548 -0
  178. package/src/ui/styled/EventsList.tsx +83 -0
  179. package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
  180. package/src/ui/styled/FormRenderer.tsx +249 -0
  181. package/src/ui/styled/InstallBanner.tsx +114 -0
  182. package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
  183. package/src/ui/styled/InvoicePayment.tsx +141 -0
  184. package/src/ui/styled/LeadMagnet.tsx +93 -0
  185. package/src/ui/styled/Loading.tsx +56 -0
  186. package/src/ui/styled/LoginForm.tsx +134 -0
  187. package/src/ui/styled/Markdown.tsx +103 -0
  188. package/src/ui/styled/MembershipCheckout.tsx +197 -0
  189. package/src/ui/styled/MembershipTiers.tsx +115 -0
  190. package/src/ui/styled/OfferButton.tsx +181 -0
  191. package/src/ui/styled/PageActions.tsx +124 -0
  192. package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
  193. package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
  194. package/src/ui/styled/Paywall.tsx +54 -0
  195. package/src/ui/styled/PlayButton.tsx +48 -0
  196. package/src/ui/styled/PodcastEpisode.tsx +137 -0
  197. package/src/ui/styled/PodcastList.tsx +58 -0
  198. package/src/ui/styled/PodcastShow.tsx +164 -0
  199. package/src/ui/styled/PostsFeed.tsx +63 -0
  200. package/src/ui/styled/ProductDetail.tsx +616 -0
  201. package/src/ui/styled/ProductGrid.tsx +82 -0
  202. package/src/ui/styled/PushOptIn.tsx +65 -0
  203. package/src/ui/styled/PwaRegistration.tsx +58 -0
  204. package/src/ui/styled/ReactionBar.tsx +93 -0
  205. package/src/ui/styled/ReplayList.tsx +134 -0
  206. package/src/ui/styled/ResetPasswordForm.tsx +127 -0
  207. package/src/ui/styled/ReviewForm.tsx +292 -0
  208. package/src/ui/styled/ReviewsSection.tsx +260 -0
  209. package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
  210. package/src/ui/styled/SignupForm.tsx +109 -0
  211. package/src/ui/styled/UserMenu.tsx +166 -0
  212. package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
  213. package/src/ui/styled/chat/ChatComposer.tsx +153 -0
  214. package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
  215. package/src/ui/styled/chat/MessageBubble.tsx +66 -0
  216. package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
  217. package/src/ui/styled/community/CommunityComposer.tsx +358 -0
  218. package/src/ui/styled/community/CommunityFeed.tsx +483 -0
  219. package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
  220. package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
  221. package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
  222. package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
  223. package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
  224. package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
  225. package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
  226. package/src/ui/styled/community/CommunityRichText.tsx +157 -0
  227. package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
  228. package/src/ui/styled/community/MemberAvatar.tsx +74 -0
  229. package/src/ui/styled/community/index.ts +23 -0
  230. package/src/ui/styled/community/util.ts +15 -0
  231. package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
  232. package/src/ui/styled/members/MemberPostCard.tsx +392 -0
  233. package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
  234. package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
  235. package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
  236. package/src/ui/styled/members/index.ts +7 -0
  237. package/src/ui/styled/rich-text.css +150 -0
  238. package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
  239. package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
  240. package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
  241. package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
  242. package/src/ui/styled/work/WorkReportBody.tsx +327 -0
  243. package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
  244. package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
  245. package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
  246. package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
  247. package/src/ui/styled/work/index.ts +10 -0
  248. package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
  249. package/src/ui/theme/contrast.ts +22 -0
  250. package/src/utils/analyticsBus.ts +31 -0
  251. package/src/utils/attribution.ts +68 -0
  252. package/src/utils/cookieConsent.ts +83 -0
  253. package/src/utils/landing.ts +153 -0
  254. package/src/utils/metaPixel.ts +122 -0
  255. 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";