@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,605 @@
1
+ import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Calendar, ChevronLeft, ChevronRight, Clock, X } from "lucide-react";
3
+ import type { BookingSlot, CoachingProduct } from "../../types/models";
4
+ import { useCoachingBooking } from "../headless/coaching/useCoachingBooking";
5
+ import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
6
+ import { useAmountFormatter } from "../format/useFormatCurrency";
7
+ import { summarizeTaxQuote } from "../format/PriceDisplay";
8
+ import { Loading } from "./Loading";
9
+ import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
10
+ import { buttonStyle } from "./Button";
11
+ import {
12
+ DialogRoot,
13
+ DialogTrigger,
14
+ DialogPortal,
15
+ DialogOverlay,
16
+ DialogContent,
17
+ DialogTitle,
18
+ DialogClose,
19
+ } from "../headless/dialog";
20
+
21
+ export interface CoachingBookingProps {
22
+ /** Coaching product slug (resolves the product + drives the booking flow). */
23
+ slug?: string;
24
+ /** Trigger button label. */
25
+ triggerText?: string;
26
+ /** Format a numeric amount for display. */
27
+ formatAmount?: (amount: number) => string;
28
+ /** Optional payment renderer — falls back to a registered <ForgePaymentProvider>. */
29
+ renderPayment?: (props: PaymentRenderProps) => ReactNode;
30
+ /** Stripe return URL for a PAID booking. Default `/i/coaching/:slug/finalise`. */
31
+ finalisePath?: (slug: string, bookingId: string) => string;
32
+ /** Host-owned navigation on FREE completion (e.g. router navigate). */
33
+ onComplete?: (info: { slug: string; bookingId: string }) => void;
34
+ }
35
+
36
+ const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
37
+
38
+ /** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
39
+ const a = (hex: string, alpha: number) => hex + Math.round(alpha * 255).toString(16).padStart(2, "0");
40
+
41
+ /**
42
+ * Themed coaching session booking — a "Book a session" trigger + modal running
43
+ * the three-step flow (slot → details → payment) on `useCoachingBooking`. Port
44
+ * of the client app's CoachingBookingModal, styled from `ForgeThemeProvider`.
45
+ */
46
+ export function CoachingBooking({
47
+ slug,
48
+ triggerText = "Select Availability",
49
+ formatAmount,
50
+ renderPayment,
51
+ finalisePath,
52
+ onComplete,
53
+ }: CoachingBookingProps) {
54
+ const theme = useForgeTheme();
55
+ const registered = usePaymentRenderer();
56
+ const fmt = useAmountFormatter(formatAmount);
57
+ const c = useCoachingBooking(slug, { finalisePath, onComplete });
58
+ const renderPay = renderPayment ?? registered ?? undefined;
59
+
60
+ const [open, setOpen] = useState(false);
61
+
62
+ const tryClose = (next: boolean) => {
63
+ if (!next && c.step !== "slot") {
64
+ if (typeof window !== "undefined" && !window.confirm("Are you sure you want to leave checkout?")) return;
65
+ // Release the held slot so it's not locked until the timer runs out.
66
+ void c.releaseReservation();
67
+ }
68
+ setOpen(next);
69
+ };
70
+
71
+ return (
72
+ <DialogRoot open={open} onOpenChange={tryClose}>
73
+ <DialogTrigger asChild>
74
+ <button style={triggerButtonStyle(theme)}>{triggerText}</button>
75
+ </DialogTrigger>
76
+ <DialogPortal>
77
+ <DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }} />
78
+ <DialogContent
79
+ style={{
80
+ position: "fixed",
81
+ top: "50%",
82
+ left: "50%",
83
+ transform: "translate(-50%, -50%)",
84
+ width: "min(640px, calc(100vw - 24px))",
85
+ maxHeight: "88vh",
86
+ overflowY: "auto",
87
+ background: theme.colors.background,
88
+ color: theme.colors.text,
89
+ borderRadius: theme.cornerRadius,
90
+ padding: 24,
91
+ zIndex: 51,
92
+ fontFamily: theme.fontFamily,
93
+ }}
94
+ aria-describedby={undefined}
95
+ >
96
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
97
+ <DialogTitle style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>
98
+ {c.product?.title ?? "Book a session"}
99
+ </DialogTitle>
100
+ <DialogClose asChild>
101
+ <button aria-label="Close" style={closeButtonStyle(theme)}>
102
+ <X size={18} />
103
+ </button>
104
+ </DialogClose>
105
+ </div>
106
+
107
+ {c.isLoading && <Loading />}
108
+ {!c.isLoading && !c.product && <p style={{ marginTop: 8 }}>Coaching session not found.</p>}
109
+ {c.product && <CoachingBookingBody c={c} product={c.product} fmt={fmt} renderPayment={renderPay} />}
110
+ </DialogContent>
111
+ </DialogPortal>
112
+ </DialogRoot>
113
+ );
114
+ }
115
+
116
+ type Booking = ReturnType<typeof useCoachingBooking>;
117
+
118
+ function CoachingBookingBody({
119
+ c,
120
+ product,
121
+ fmt,
122
+ renderPayment,
123
+ }: {
124
+ c: Booking;
125
+ product: CoachingProduct;
126
+ fmt: (n: number) => string;
127
+ renderPayment?: (props: PaymentRenderProps) => ReactNode;
128
+ }) {
129
+ const stepIndex = c.step === "slot" ? 0 : c.step === "details" ? 1 : 2;
130
+ return (
131
+ <div style={{ width: "100%" }}>
132
+ {/* Reservation hold countdown — isolated so its per-second tick re-renders
133
+ only itself, not the modal body (esp. the Stripe payment form). */}
134
+ <ReservationTimer expiresAt={c.reservationExpiresAt} onExpire={c.expireReservation} />
135
+ <Progress current={stepIndex} />
136
+ {c.step === "slot" && <SlotStep c={c} product={product} fmt={fmt} />}
137
+ {c.step === "details" && <DetailsStep c={c} product={product} fmt={fmt} />}
138
+ {c.step === "payment" && <PaymentStep c={c} product={product} fmt={fmt} renderPayment={renderPayment} />}
139
+ </div>
140
+ );
141
+ }
142
+
143
+ // ── Reservation hold countdown ────────────────────────────────────────────────
144
+ // Owns its own per-second tick so only this component re-renders each second
145
+ // (keeps the Stripe form in the payment step from re-mounting). Fires `onExpire`
146
+ // exactly once when the hold runs out.
147
+ function ReservationTimer({ expiresAt, onExpire }: { expiresAt: number | null; onExpire: () => void }) {
148
+ const theme = useForgeTheme();
149
+ const [now, setNow] = useState(() => Date.now());
150
+ const firedRef = useRef(false);
151
+ const onExpireRef = useRef(onExpire);
152
+ onExpireRef.current = onExpire;
153
+
154
+ useEffect(() => {
155
+ if (expiresAt == null) return;
156
+ firedRef.current = false;
157
+ setNow(Date.now());
158
+ const id = setInterval(() => setNow(Date.now()), 1000);
159
+ return () => clearInterval(id);
160
+ }, [expiresAt]);
161
+
162
+ const remaining = expiresAt == null ? 0 : Math.max(0, Math.floor((expiresAt - now) / 1000));
163
+
164
+ useEffect(() => {
165
+ if (expiresAt != null && remaining === 0 && !firedRef.current) {
166
+ firedRef.current = true;
167
+ onExpireRef.current();
168
+ }
169
+ }, [remaining, expiresAt]);
170
+
171
+ if (expiresAt == null) return null;
172
+
173
+ const danger = remaining <= 60;
174
+ const color = danger ? "#ef4444" : theme.colors.primary;
175
+ const mmss = `${Math.floor(remaining / 60)
176
+ .toString()
177
+ .padStart(2, "0")}:${(remaining % 60).toString().padStart(2, "0")}`;
178
+
179
+ return (
180
+ <div
181
+ style={{
182
+ marginBottom: 16,
183
+ padding: 12,
184
+ borderRadius: theme.cornerRadius,
185
+ display: "flex",
186
+ alignItems: "center",
187
+ justifyContent: "center",
188
+ gap: 8,
189
+ background: a(color, danger ? 0.12 : 0.06),
190
+ border: `1px solid ${a(color, 0.4)}`,
191
+ }}
192
+ >
193
+ <Clock size={16} style={{ color }} />
194
+ <span style={{ fontWeight: 600, fontSize: 14, color }}>Time remaining: {mmss}</span>
195
+ </div>
196
+ );
197
+ }
198
+
199
+ // ── Progress indicator (3 numbered steps) ────────────────────────────────────
200
+ function Progress({ current }: { current: number }) {
201
+ const theme = useForgeTheme();
202
+ const steps = [0, 1, 2];
203
+ return (
204
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 24 }}>
205
+ {steps.map((s, index) => (
206
+ <div key={s} style={{ display: "flex", alignItems: "center", flex: index < steps.length - 1 ? 1 : "0 0 auto" }}>
207
+ <div
208
+ style={{
209
+ width: 32,
210
+ height: 32,
211
+ borderRadius: "9999px",
212
+ display: "flex",
213
+ alignItems: "center",
214
+ justifyContent: "center",
215
+ fontSize: 14,
216
+ fontWeight: 600,
217
+ border: `1px solid ${index <= current ? theme.colors.primary : a(theme.colors.primary, 0.25)}`,
218
+ background: index <= current ? theme.colors.primary : "transparent",
219
+ color: index <= current ? "#fff" : theme.colors.primary,
220
+ }}
221
+ >
222
+ {index + 1}
223
+ </div>
224
+ {index < steps.length - 1 && (
225
+ <div
226
+ style={{
227
+ flex: 1,
228
+ height: 2,
229
+ margin: "0 16px",
230
+ background: index < current ? theme.colors.primary : a(theme.colors.primary, 0.13),
231
+ }}
232
+ />
233
+ )}
234
+ </div>
235
+ ))}
236
+ </div>
237
+ );
238
+ }
239
+
240
+ // ── Step 1: week navigation + slot selection (reserve happens on Continue) ─────
241
+ function SlotStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; fmt: (n: number) => string }) {
242
+ const theme = useForgeTheme();
243
+ const slots: BookingSlot[] = c.availability.data ?? [];
244
+
245
+ const grouped = useMemo(() => {
246
+ const byDay: Record<string, { slotId: string; time: string; ts: number }[]> = {};
247
+ for (const slot of slots) {
248
+ const date = new Date(slot.startTime);
249
+ const dateKey = date.toISOString().split("T")[0];
250
+ if (!dateKey) continue;
251
+ // Shown in the visitor's own timezone (like the client app + the confirmation).
252
+ const time = date.toLocaleTimeString("en-US", {
253
+ hour: "numeric",
254
+ minute: "2-digit",
255
+ hour12: true,
256
+ });
257
+ (byDay[dateKey] ??= []).push({ slotId: slot.id, time, ts: date.getTime() });
258
+ }
259
+ for (const key of Object.keys(byDay)) byDay[key]!.sort((x, y) => x.ts - y.ts);
260
+ return Object.entries(byDay).sort(([x], [y]) => x.localeCompare(y));
261
+ }, [slots]);
262
+
263
+ // The visitor's local timezone abbreviation (e.g. "EDT") — slots are shown in it.
264
+ const tzAbbr = (() => {
265
+ try {
266
+ return (
267
+ new Intl.DateTimeFormat("en-US", { timeZoneName: "short" })
268
+ .formatToParts(new Date())
269
+ .find((p) => p.type === "timeZoneName")?.value ?? ""
270
+ );
271
+ } catch {
272
+ return "";
273
+ }
274
+ })();
275
+
276
+ const weekLabel = `${c.weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" })} – ${c.weekEnd.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
277
+ const navButton = (disabled: boolean): CSSProperties => ({
278
+ padding: 8,
279
+ borderRadius: theme.cornerRadius,
280
+ border: `1px solid ${a(theme.colors.primary, 0.25)}`,
281
+ background: "transparent",
282
+ color: disabled ? a(theme.colors.text, 0.3) : theme.colors.text,
283
+ cursor: disabled ? "not-allowed" : "pointer",
284
+ display: "flex",
285
+ alignItems: "center",
286
+ justifyContent: "center",
287
+ });
288
+
289
+ return (
290
+ <div>
291
+ <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 6 }}>Select Availability</h2>
292
+ <p style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13, color: a(theme.colors.text, 0.6), margin: "0 0 16px" }}>
293
+ <Clock size={14} /> Times shown in your timezone{tzAbbr ? ` (${tzAbbr})` : ""}
294
+ </p>
295
+
296
+ {/* Week navigation */}
297
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
298
+ <span style={{ fontSize: 14, color: a(theme.colors.text, 0.6) }}>{weekLabel}</span>
299
+ <div style={{ display: "flex", gap: 8 }}>
300
+ <button aria-label="Previous week" onClick={c.goPrevWeek} disabled={!c.canGoPrevWeek} style={navButton(!c.canGoPrevWeek)}>
301
+ <ChevronLeft size={16} />
302
+ </button>
303
+ <button aria-label="Next week" onClick={c.goNextWeek} style={navButton(false)}>
304
+ <ChevronRight size={16} />
305
+ </button>
306
+ </div>
307
+ </div>
308
+
309
+ {c.availability.isLoading ? (
310
+ <Loading label="Loading available slots…" />
311
+ ) : grouped.length === 0 ? (
312
+ <p style={{ opacity: 0.7 }}>No available slots for this week.</p>
313
+ ) : (
314
+ <div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
315
+ {grouped.map(([dateKey, daySlots]) => {
316
+ const date = new Date(dateKey);
317
+ const dayName = DAY_NAMES[date.getUTCDay()];
318
+ const dateString = date.toLocaleDateString("en-US", {
319
+ month: "short",
320
+ day: "numeric",
321
+ year: "numeric",
322
+ timeZone: "UTC",
323
+ });
324
+ return (
325
+ <div key={dateKey}>
326
+ <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
327
+ <Calendar size={18} style={{ color: theme.colors.primary }} />
328
+ <h3 style={{ fontSize: 17, fontWeight: 600, margin: 0 }}>
329
+ {dayName}, {dateString}
330
+ </h3>
331
+ </div>
332
+ <div className="grid grid-cols-2 md:grid-cols-4" style={{ gap: 10 }}>
333
+ {daySlots.map((slot) => {
334
+ const selected = slot.slotId === c.selectedSlotId;
335
+ return (
336
+ <button
337
+ key={slot.slotId}
338
+ onClick={() => c.selectSlot(slot.slotId)}
339
+ disabled={c.isProcessing}
340
+ style={{
341
+ padding: "12px",
342
+ borderRadius: theme.cornerRadius,
343
+ border: `2px solid ${selected ? theme.colors.primary : a(theme.colors.primary, 0.25)}`,
344
+ background: selected ? a(theme.colors.primary, 0.06) : "transparent",
345
+ color: selected ? theme.colors.primary : theme.colors.text,
346
+ cursor: c.isProcessing ? "not-allowed" : "pointer",
347
+ fontWeight: 600,
348
+ fontFamily: theme.fontFamily,
349
+ }}
350
+ >
351
+ {slot.time}
352
+ </button>
353
+ );
354
+ })}
355
+ </div>
356
+ </div>
357
+ );
358
+ })}
359
+ </div>
360
+ )}
361
+
362
+ {c.error && <p style={{ color: "#ef4444", fontSize: 14, marginTop: 12 }}>{c.error}</p>}
363
+ <ActionBar
364
+ c={c}
365
+ product={product}
366
+ fmt={fmt}
367
+ onNext={c.reserveSelected}
368
+ nextLabel={c.isProcessing ? "Reserving…" : "Continue"}
369
+ nextDisabled={!c.selectedSlotId || c.isProcessing}
370
+ />
371
+ </div>
372
+ );
373
+ }
374
+
375
+ // ── Step 2: buyer details + questionnaire ─────────────────────────────────────
376
+ function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; fmt: (n: number) => string }) {
377
+ const theme = useForgeTheme();
378
+ const [confirmEmail, setConfirmEmail] = useState("");
379
+ const [answers, setAnswers] = useState<Record<string, string>>({});
380
+ const [localError, setLocalError] = useState<string | null>(null);
381
+
382
+ const questions = useMemo(() => product.questionnaire ?? [], [product.questionnaire]);
383
+
384
+ const onAnswer = (qid: string, value: string) => {
385
+ const next = { ...answers, [qid]: value };
386
+ setAnswers(next);
387
+ c.setQuestionnaire(questions.map((q) => ({ ...q, answer: next[q.id] ?? "" })));
388
+ };
389
+
390
+ const onContinue = () => {
391
+ setLocalError(null);
392
+ if (!c.firstName.trim() || !c.lastName.trim()) return setLocalError("Please enter your name.");
393
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email)) return setLocalError("Please enter a valid email.");
394
+ if (c.email !== confirmEmail) return setLocalError("Emails do not match.");
395
+ for (const q of questions) {
396
+ if (!q.optional && !(answers[q.id] ?? "").trim()) return setLocalError(`Please answer: ${q.question}`);
397
+ }
398
+ c.continueToPayment();
399
+ };
400
+
401
+ const isFree = Number(product.price) <= 0;
402
+
403
+ return (
404
+ <div>
405
+ <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 4 }}>Contact Information</h2>
406
+ <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>
407
+ Please provide your contact information for the booking.
408
+ </p>
409
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
410
+ <div style={{ display: "flex", gap: 8 }}>
411
+ <input placeholder="First name" value={c.firstName} onChange={(e) => c.setFirstName(e.target.value)} style={inputStyle(theme)} />
412
+ <input placeholder="Last name" value={c.lastName} onChange={(e) => c.setLastName(e.target.value)} style={inputStyle(theme)} />
413
+ </div>
414
+ <div>
415
+ <input placeholder="Email" type="email" value={c.email} onChange={(e) => c.setEmail(e.target.value)} style={inputStyle(theme)} />
416
+ {c.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email) && <FieldError>Please enter a valid email address</FieldError>}
417
+ </div>
418
+ <div>
419
+ <input placeholder="Confirm email" type="email" value={confirmEmail} onChange={(e) => setConfirmEmail(e.target.value)} style={inputStyle(theme)} />
420
+ {confirmEmail && c.email !== confirmEmail && <FieldError>Email addresses do not match</FieldError>}
421
+ </div>
422
+
423
+ {questions.map((q) => {
424
+ const missing = !q.optional && !(answers[q.id] ?? "").trim();
425
+ return (
426
+ <label key={q.id} style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 14 }}>
427
+ <span>
428
+ {q.question}
429
+ {!q.optional && <span style={{ color: "#ef4444" }}> *</span>}
430
+ </span>
431
+ {q.type === "select" && q.options && q.options.length > 0 ? (
432
+ <select value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} style={inputStyle(theme)}>
433
+ <option value="">Select…</option>
434
+ {q.options.map((opt) => (
435
+ <option key={opt} value={opt}>
436
+ {opt}
437
+ </option>
438
+ ))}
439
+ </select>
440
+ ) : q.type === "textarea" ? (
441
+ <textarea value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} rows={3} style={inputStyle(theme)} />
442
+ ) : (
443
+ <input value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} style={inputStyle(theme)} />
444
+ )}
445
+ {missing && <FieldError>This field is required</FieldError>}
446
+ </label>
447
+ );
448
+ })}
449
+
450
+ {(localError || c.error) && <p style={{ color: "#ef4444", fontSize: 14 }}>{localError ?? c.error}</p>}
451
+
452
+ <p style={{ fontSize: 12, color: a(theme.colors.text, 0.6), marginTop: 4 }}>
453
+ By continuing, you agree to receive booking confirmations and important updates about your session via email.
454
+ </p>
455
+ </div>
456
+ <ActionBar
457
+ c={c}
458
+ product={product}
459
+ fmt={fmt}
460
+ onBack={() => void c.releaseReservation()}
461
+ onNext={onContinue}
462
+ nextLabel={c.isProcessing ? "Processing…" : isFree ? "Confirm Booking" : "Continue to payment"}
463
+ nextDisabled={c.isProcessing}
464
+ />
465
+ </div>
466
+ );
467
+ }
468
+
469
+ function FieldError({ children }: { children: ReactNode }) {
470
+ return <p style={{ color: "#ef4444", fontSize: 13, marginTop: 4 }}>{children}</p>;
471
+ }
472
+
473
+ // ── Step 3: payment ───────────────────────────────────────────────────────────
474
+ function PaymentStep({
475
+ c,
476
+ product,
477
+ fmt,
478
+ renderPayment,
479
+ }: {
480
+ c: Booking;
481
+ product: CoachingProduct;
482
+ fmt: (n: number) => string;
483
+ renderPayment?: (props: PaymentRenderProps) => ReactNode;
484
+ }) {
485
+ const theme = useForgeTheme();
486
+ const total = c.totalAmount ?? Number(product.price);
487
+ // Authoritative tax quote from start-payment — the charged total already
488
+ // reflects it; we only caption the breakdown (display only).
489
+ const taxSummary = summarizeTaxQuote(c.taxQuote);
490
+ return (
491
+ <div>
492
+ <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>Payment</h2>
493
+ <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>Complete your purchase to secure your booking.</p>
494
+ <div
495
+ style={{
496
+ padding: 20,
497
+ border: `2px solid ${a(theme.colors.primary, 0.25)}`,
498
+ borderRadius: theme.cornerRadius,
499
+ marginBottom: 16,
500
+ display: "flex",
501
+ justifyContent: "space-between",
502
+ alignItems: "center",
503
+ }}
504
+ >
505
+ <span style={{ fontSize: 16 }}>Total Amount</span>
506
+ <span style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary, textAlign: "right" }}>
507
+ {fmt(total)}
508
+ {taxSummary && (
509
+ <span style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}>
510
+ {taxSummary.mode === "inclusive" ? taxSummary.label : `incl. ${taxSummary.formattedTax} tax`}
511
+ </span>
512
+ )}
513
+ </span>
514
+ </div>
515
+ {!renderPayment ? (
516
+ <p style={{ color: "#ef4444", fontSize: 14 }}>Payment is not configured.</p>
517
+ ) : c.clientSecret ? (
518
+ renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount: total, mode: "redirect" })
519
+ ) : (
520
+ <p style={{ opacity: 0.7 }}>Preparing payment…</p>
521
+ )}
522
+ <button onClick={() => c.setStep("details")} style={{ ...secondaryButtonStyle(theme), marginTop: 16 }}>
523
+ Back
524
+ </button>
525
+ </div>
526
+ );
527
+ }
528
+
529
+ // ── Shared bits ───────────────────────────────────────────────────────────────
530
+ function ActionBar({
531
+ c,
532
+ product,
533
+ fmt,
534
+ onBack,
535
+ onNext,
536
+ nextLabel,
537
+ nextDisabled,
538
+ }: {
539
+ c: Booking;
540
+ product: CoachingProduct;
541
+ fmt: (n: number) => string;
542
+ onBack?: () => void;
543
+ onNext?: () => void;
544
+ nextLabel?: string;
545
+ nextDisabled?: boolean;
546
+ }) {
547
+ const theme = useForgeTheme();
548
+ const total = c.totalAmount ?? Number(product.price);
549
+ return (
550
+ <div style={{ marginTop: 24 }}>
551
+ <div
552
+ style={{
553
+ padding: 16,
554
+ border: `1px solid ${a(theme.colors.primary, 0.25)}`,
555
+ borderRadius: theme.cornerRadius,
556
+ marginBottom: 16,
557
+ display: "flex",
558
+ justifyContent: "space-between",
559
+ alignItems: "center",
560
+ }}
561
+ >
562
+ <span style={{ fontWeight: 600 }}>Total</span>
563
+ <span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(total)}</span>
564
+ </div>
565
+ {(onBack || onNext) && (
566
+ <div style={{ display: "flex", gap: 8, justifyContent: "space-between" }}>
567
+ <button onClick={onBack} disabled={!onBack} style={{ ...secondaryButtonStyle(theme), opacity: onBack ? 1 : 0.4 }}>
568
+ Back
569
+ </button>
570
+ {onNext && (
571
+ <button onClick={onNext} disabled={nextDisabled} style={{ ...primaryButtonStyle(theme), opacity: nextDisabled ? 0.6 : 1 }}>
572
+ {nextLabel}
573
+ </button>
574
+ )}
575
+ </div>
576
+ )}
577
+ </div>
578
+ );
579
+ }
580
+
581
+ const triggerButtonStyle = (t: ForgeTheme): CSSProperties => buttonStyle(t, { variant: "primary", fullWidth: true });
582
+
583
+ const primaryButtonStyle = (t: ForgeTheme): CSSProperties => buttonStyle(t, { variant: "primary" });
584
+
585
+ const secondaryButtonStyle = (t: ForgeTheme): CSSProperties => buttonStyle(t, { variant: "outline" });
586
+
587
+ const closeButtonStyle = (t: ForgeTheme): CSSProperties => ({
588
+ background: "transparent",
589
+ border: "none",
590
+ color: t.colors.text,
591
+ cursor: "pointer",
592
+ fontSize: 16,
593
+ lineHeight: 1,
594
+ opacity: 0.7,
595
+ });
596
+
597
+ const inputStyle = (t: ForgeTheme): CSSProperties => ({
598
+ width: "100%",
599
+ padding: 10,
600
+ border: `1px solid ${a(t.colors.primary, 0.25)}`,
601
+ borderRadius: t.cornerRadius,
602
+ background: t.colors.background,
603
+ color: t.colors.text,
604
+ fontFamily: t.fontFamily,
605
+ });