@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,425 @@
1
+ import { type CSSProperties, type ReactNode, useMemo, useState } from "react";
2
+ import { X } from "lucide-react";
3
+ import type { PublicCourse, QuestionnaireQuestion } from "../../types/models";
4
+ import { useCourseCheckout } from "../headless/course/useCourseCheckout";
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 CourseCheckoutProps {
22
+ /** Course slug (resolves the course + drives the purchase 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 enrollment. Default `/i/courses/: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
+ /** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
37
+ const a = (hex: string, alpha: number) => hex + Math.round(alpha * 255).toString(16).padStart(2, "0");
38
+
39
+ /**
40
+ * Themed course purchase — an "Enroll" trigger + modal running the two-step flow
41
+ * (buyer details → payment) on `useCourseCheckout`. Port of the client app's
42
+ * CoursePurchaseModal, styled from `ForgeThemeProvider`. Free courses skip
43
+ * payment and auto-redirect to the finalise page inside the hook.
44
+ */
45
+ export function CourseCheckout({
46
+ slug,
47
+ triggerText = "Enroll",
48
+ formatAmount,
49
+ renderPayment,
50
+ finalisePath,
51
+ onComplete,
52
+ }: CourseCheckoutProps) {
53
+ const theme = useForgeTheme();
54
+ const registered = usePaymentRenderer();
55
+ const fmt = useAmountFormatter(formatAmount);
56
+ const c = useCourseCheckout(slug, { finalisePath, onComplete });
57
+ const renderPay = renderPayment ?? registered ?? undefined;
58
+
59
+ const [open, setOpen] = useState(false);
60
+
61
+ const tryClose = (next: boolean) => {
62
+ if (!next && c.step !== "details" && typeof window !== "undefined") {
63
+ if (!window.confirm("Are you sure you want to leave checkout?")) return;
64
+ }
65
+ setOpen(next);
66
+ };
67
+
68
+ return (
69
+ <DialogRoot open={open} onOpenChange={tryClose}>
70
+ <DialogTrigger asChild>
71
+ <button style={triggerButtonStyle(theme)}>{triggerText}</button>
72
+ </DialogTrigger>
73
+ <DialogPortal>
74
+ <DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }} />
75
+ <DialogContent
76
+ style={{
77
+ position: "fixed",
78
+ top: "50%",
79
+ left: "50%",
80
+ transform: "translate(-50%, -50%)",
81
+ width: "min(640px, calc(100vw - 24px))",
82
+ maxHeight: "88vh",
83
+ overflowY: "auto",
84
+ background: theme.colors.background,
85
+ color: theme.colors.text,
86
+ borderRadius: theme.cornerRadius,
87
+ padding: 24,
88
+ zIndex: 51,
89
+ fontFamily: theme.fontFamily,
90
+ }}
91
+ aria-describedby={undefined}
92
+ >
93
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
94
+ <DialogTitle style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>{c.course?.title ?? "Checkout"}</DialogTitle>
95
+ <DialogClose asChild>
96
+ <button aria-label="Close" style={closeButtonStyle(theme)}>
97
+ <X size={18} />
98
+ </button>
99
+ </DialogClose>
100
+ </div>
101
+
102
+ {c.isLoading && <Loading />}
103
+ {!c.isLoading && !c.course && <p style={{ marginTop: 8 }}>Course not found.</p>}
104
+ {c.course && <CourseCheckoutBody c={c} course={c.course} fmt={fmt} renderPayment={renderPay} />}
105
+ </DialogContent>
106
+ </DialogPortal>
107
+ </DialogRoot>
108
+ );
109
+ }
110
+
111
+ type Checkout = ReturnType<typeof useCourseCheckout>;
112
+
113
+ function CourseCheckoutBody({
114
+ c,
115
+ course,
116
+ fmt,
117
+ renderPayment,
118
+ }: {
119
+ c: Checkout;
120
+ course: PublicCourse;
121
+ fmt: (n: number) => string;
122
+ renderPayment?: (props: PaymentRenderProps) => ReactNode;
123
+ }) {
124
+ const stepIndex = c.step === "details" ? 0 : 1;
125
+ return (
126
+ <div style={{ width: "100%" }}>
127
+ <Progress current={stepIndex} />
128
+ {c.step === "details" && <DetailsStep c={c} course={course} fmt={fmt} />}
129
+ {c.step === "payment" && <PaymentStep c={c} course={course} fmt={fmt} renderPayment={renderPayment} />}
130
+ </div>
131
+ );
132
+ }
133
+
134
+ // ── Progress indicator (2 numbered steps) ─────────────────────────────────────
135
+ function Progress({ current }: { current: number }) {
136
+ const theme = useForgeTheme();
137
+ const steps = [0, 1];
138
+ return (
139
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 24 }}>
140
+ {steps.map((s, index) => (
141
+ <div key={s} style={{ display: "flex", alignItems: "center", flex: index < steps.length - 1 ? 1 : "0 0 auto" }}>
142
+ <div
143
+ style={{
144
+ width: 32,
145
+ height: 32,
146
+ borderRadius: "9999px",
147
+ display: "flex",
148
+ alignItems: "center",
149
+ justifyContent: "center",
150
+ fontSize: 14,
151
+ fontWeight: 600,
152
+ border: `1px solid ${index <= current ? theme.colors.primary : a(theme.colors.primary, 0.25)}`,
153
+ background: index <= current ? theme.colors.primary : "transparent",
154
+ color: index <= current ? "#fff" : theme.colors.primary,
155
+ }}
156
+ >
157
+ {index + 1}
158
+ </div>
159
+ {index < steps.length - 1 && (
160
+ <div
161
+ style={{
162
+ flex: 1,
163
+ height: 2,
164
+ margin: "0 16px",
165
+ background: index < current ? theme.colors.primary : a(theme.colors.primary, 0.13),
166
+ }}
167
+ />
168
+ )}
169
+ </div>
170
+ ))}
171
+ </div>
172
+ );
173
+ }
174
+
175
+ // ── Step 1: buyer details + questionnaire ─────────────────────────────────────
176
+ function DetailsStep({ c, course, fmt }: { c: Checkout; course: PublicCourse; fmt: (n: number) => string }) {
177
+ const theme = useForgeTheme();
178
+ const [confirmEmail, setConfirmEmail] = useState("");
179
+ const [answers, setAnswers] = useState<Record<string, string>>({});
180
+ const [localError, setLocalError] = useState<string | null>(null);
181
+
182
+ const price = Number(course.price);
183
+ const compareAt = course.compareAtPrice != null ? Number(course.compareAtPrice) : null;
184
+ const isFree = price <= 0;
185
+ const questions = useMemo<QuestionnaireQuestion[]>(() => course.questionnaire ?? [], [course.questionnaire]);
186
+
187
+ const onAnswer = (qid: string, value: string) => {
188
+ const next = { ...answers, [qid]: value };
189
+ setAnswers(next);
190
+ c.setQuestionnaire(questions.map((q) => ({ ...q, answer: next[q.id] ?? "" })));
191
+ };
192
+
193
+ const onContinue = () => {
194
+ setLocalError(null);
195
+ if (!c.firstName.trim() || !c.lastName.trim()) return setLocalError("Please enter your name.");
196
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email)) return setLocalError("Please enter a valid email.");
197
+ if (c.email !== confirmEmail) return setLocalError("Emails do not match.");
198
+ for (const q of questions) {
199
+ if (!q.optional && !(answers[q.id] ?? "").trim()) return setLocalError(`Please answer: ${q.question}`);
200
+ }
201
+ c.continueToPayment();
202
+ };
203
+
204
+ return (
205
+ <div>
206
+ <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 4 }}>{isFree ? "Get access" : "Contact Information"}</h2>
207
+ <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>
208
+ Please provide your contact information for the purchase.
209
+ </p>
210
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
211
+ <div style={{ display: "flex", gap: 8 }}>
212
+ <input placeholder="First name" value={c.firstName} onChange={(e) => c.setFirstName(e.target.value)} style={inputStyle(theme)} />
213
+ <input placeholder="Last name" value={c.lastName} onChange={(e) => c.setLastName(e.target.value)} style={inputStyle(theme)} />
214
+ </div>
215
+ <div>
216
+ <input placeholder="Email" type="email" value={c.email} onChange={(e) => c.setEmail(e.target.value)} style={inputStyle(theme)} />
217
+ {c.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email) && <FieldError>Please enter a valid email address</FieldError>}
218
+ </div>
219
+ <div>
220
+ <input placeholder="Confirm email" type="email" value={confirmEmail} onChange={(e) => setConfirmEmail(e.target.value)} style={inputStyle(theme)} />
221
+ {confirmEmail && c.email !== confirmEmail && <FieldError>Email addresses do not match</FieldError>}
222
+ </div>
223
+
224
+ {questions.length > 0 && (
225
+ <div style={{ marginTop: 8, paddingTop: 16, borderTop: `1px solid ${a(theme.colors.primary, 0.13)}` }}>
226
+ <h3 style={{ fontSize: 17, fontWeight: 700, marginBottom: 12 }}>Additional Information</h3>
227
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
228
+ {questions.map((q) => {
229
+ const missing = !q.optional && !(answers[q.id] ?? "").trim();
230
+ return (
231
+ <label key={q.id} style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 14 }}>
232
+ <span>
233
+ {q.question}
234
+ {!q.optional && <span style={{ color: "#ef4444" }}> *</span>}
235
+ </span>
236
+ {q.type === "select" && q.options && q.options.length > 0 ? (
237
+ <select value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} style={inputStyle(theme)}>
238
+ <option value="">Select…</option>
239
+ {q.options.map((opt) => (
240
+ <option key={opt} value={opt}>
241
+ {opt}
242
+ </option>
243
+ ))}
244
+ </select>
245
+ ) : q.type === "textarea" ? (
246
+ <textarea
247
+ value={answers[q.id] ?? ""}
248
+ onChange={(e) => onAnswer(q.id, e.target.value)}
249
+ rows={3}
250
+ style={{ ...inputStyle(theme), resize: "vertical" }}
251
+ />
252
+ ) : (
253
+ <input value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} style={inputStyle(theme)} />
254
+ )}
255
+ {missing && <FieldError>This field is required</FieldError>}
256
+ </label>
257
+ );
258
+ })}
259
+ </div>
260
+ </div>
261
+ )}
262
+
263
+ <p style={{ fontSize: 12, opacity: 0.6 }}>
264
+ By continuing, you agree to receive course access and important updates via email.
265
+ </p>
266
+
267
+ {(localError || c.error) && <p style={{ color: "#ef4444", fontSize: 14 }}>{localError ?? c.error}</p>}
268
+ </div>
269
+ <ActionBar
270
+ fmt={fmt}
271
+ amount={price}
272
+ compareAt={compareAt}
273
+ onNext={onContinue}
274
+ nextLabel={c.isProcessing ? "Processing…" : isFree ? "Confirm Purchase" : "Continue to Payment"}
275
+ nextDisabled={c.isProcessing}
276
+ />
277
+ </div>
278
+ );
279
+ }
280
+
281
+ // ── Step 2: payment ───────────────────────────────────────────────────────────
282
+ function PaymentStep({
283
+ c,
284
+ course,
285
+ fmt,
286
+ renderPayment,
287
+ }: {
288
+ c: Checkout;
289
+ course: PublicCourse;
290
+ fmt: (n: number) => string;
291
+ renderPayment?: (props: PaymentRenderProps) => ReactNode;
292
+ }) {
293
+ const theme = useForgeTheme();
294
+ const amount = Number(course.price);
295
+ const compareAt = course.compareAtPrice != null ? Number(course.compareAtPrice) : null;
296
+ // Authoritative tax quote from start-payment (display only) — the charged
297
+ // amount already reflects it.
298
+ const taxSummary = summarizeTaxQuote(c.taxQuote);
299
+ return (
300
+ <div>
301
+ <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>Payment</h2>
302
+ <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>Complete your purchase to access the course.</p>
303
+ <div
304
+ style={{
305
+ padding: 20,
306
+ border: `2px solid ${a(theme.colors.primary, 0.25)}`,
307
+ borderRadius: theme.cornerRadius,
308
+ marginBottom: 16,
309
+ display: "flex",
310
+ justifyContent: "space-between",
311
+ alignItems: "center",
312
+ }}
313
+ >
314
+ <span style={{ fontSize: 16 }}>Total Amount</span>
315
+ <span style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary }}>
316
+ {compareAt != null && compareAt > amount && (
317
+ <span style={{ textDecoration: "line-through", opacity: 0.6, marginRight: 8, fontWeight: 400, fontSize: 16 }}>
318
+ {fmt(compareAt)}
319
+ </span>
320
+ )}
321
+ {fmt(amount)}
322
+ {taxSummary && (
323
+ <span style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}>
324
+ {taxSummary.mode === "inclusive" ? taxSummary.label : `+ ${taxSummary.formattedTax} tax`}
325
+ </span>
326
+ )}
327
+ </span>
328
+ </div>
329
+ {!renderPayment ? (
330
+ <p style={{ color: "#ef4444", fontSize: 14 }}>Payment is not configured.</p>
331
+ ) : c.clientSecret ? (
332
+ renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount, mode: "redirect" })
333
+ ) : (
334
+ <p style={{ opacity: 0.7 }}>Preparing payment…</p>
335
+ )}
336
+ <button onClick={() => c.setStep("details")} style={{ ...secondaryButtonStyle(theme), marginTop: 16 }}>
337
+ Back
338
+ </button>
339
+ </div>
340
+ );
341
+ }
342
+
343
+ function FieldError({ children }: { children: ReactNode }) {
344
+ return <p style={{ color: "#ef4444", fontSize: 13, marginTop: 4 }}>{children}</p>;
345
+ }
346
+
347
+ // ── Shared bits ───────────────────────────────────────────────────────────────
348
+ function ActionBar({
349
+ fmt,
350
+ amount,
351
+ compareAt,
352
+ onBack,
353
+ onNext,
354
+ nextLabel,
355
+ nextDisabled,
356
+ }: {
357
+ fmt: (n: number) => string;
358
+ amount: number;
359
+ compareAt?: number | null;
360
+ onBack?: () => void;
361
+ onNext: () => void;
362
+ nextLabel: string;
363
+ nextDisabled?: boolean;
364
+ }) {
365
+ const theme = useForgeTheme();
366
+ return (
367
+ <div style={{ marginTop: 24 }}>
368
+ <div
369
+ style={{
370
+ padding: 16,
371
+ border: `1px solid ${a(theme.colors.primary, 0.25)}`,
372
+ borderRadius: theme.cornerRadius,
373
+ marginBottom: 16,
374
+ display: "flex",
375
+ justifyContent: "space-between",
376
+ alignItems: "center",
377
+ }}
378
+ >
379
+ <span style={{ fontWeight: 600 }}>Total</span>
380
+ <span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>
381
+ {compareAt != null && compareAt > amount && (
382
+ <span style={{ textDecoration: "line-through", opacity: 0.6, marginRight: 8, fontWeight: 400, fontSize: 14 }}>
383
+ {fmt(compareAt)}
384
+ </span>
385
+ )}
386
+ {fmt(amount)}
387
+ </span>
388
+ </div>
389
+ <div style={{ display: "flex", gap: 8, justifyContent: "space-between" }}>
390
+ <button onClick={onBack} disabled={!onBack} style={{ ...secondaryButtonStyle(theme), opacity: onBack ? 1 : 0.4 }}>
391
+ Back
392
+ </button>
393
+ <button onClick={onNext} disabled={nextDisabled} style={{ ...primaryButtonStyle(theme), opacity: nextDisabled ? 0.6 : 1 }}>
394
+ {nextLabel}
395
+ </button>
396
+ </div>
397
+ </div>
398
+ );
399
+ }
400
+
401
+ const triggerButtonStyle = (t: ForgeTheme): CSSProperties => buttonStyle(t, { variant: "primary", fullWidth: true });
402
+
403
+ const primaryButtonStyle = (t: ForgeTheme): CSSProperties => buttonStyle(t, { variant: "primary" });
404
+
405
+ const secondaryButtonStyle = (t: ForgeTheme): CSSProperties => buttonStyle(t, { variant: "outline" });
406
+
407
+ const closeButtonStyle = (t: ForgeTheme): CSSProperties => ({
408
+ background: "transparent",
409
+ border: "none",
410
+ color: t.colors.text,
411
+ cursor: "pointer",
412
+ fontSize: 16,
413
+ lineHeight: 1,
414
+ opacity: 0.7,
415
+ });
416
+
417
+ const inputStyle = (t: ForgeTheme): CSSProperties => ({
418
+ width: "100%",
419
+ padding: 10,
420
+ border: `1px solid ${a(t.colors.primary, 0.25)}`,
421
+ borderRadius: t.cornerRadius,
422
+ background: t.colors.background,
423
+ color: t.colors.text,
424
+ fontFamily: t.fontFamily,
425
+ });
@@ -0,0 +1,191 @@
1
+ import { AlertTriangle, ArrowRight, GraduationCap, Mail } from "lucide-react";
2
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
+ import { readableTextOn } from "../theme/contrast";
4
+ import { useAmountFormatter } from "../format/useFormatCurrency";
5
+ import { Loading } from "./Loading";
6
+ import { ConfirmationStage, ConfirmationCard, CheckSeal, WarnSeal, Perforation, ConfirmationRow, alpha } from "./Confirmation";
7
+ import { useCourseBookingFinalize } from "../../data/queries/useFinalize";
8
+ import { useGetCourse } from "../../data/queries/useCourses";
9
+
10
+ export interface CourseConfirmationProps {
11
+ slug: string;
12
+ bookingId?: string;
13
+ /** Formats a monetary amount for display. Defaults to a plain USD-style `$0.00`. */
14
+ formatAmount?: (n: number) => string;
15
+ /** Where the "browse more courses" action links. */
16
+ explorePath?: string;
17
+ }
18
+
19
+ export function CourseConfirmation({
20
+ slug,
21
+ bookingId,
22
+ formatAmount,
23
+ explorePath = "/i/courses",
24
+ }: CourseConfirmationProps) {
25
+ const theme = useForgeTheme();
26
+ const fmt = useAmountFormatter(formatAmount);
27
+ const { data, isLoading, error } = useCourseBookingFinalize(slug, bookingId);
28
+ const { data: course } = useGetCourse(slug);
29
+
30
+ const text = theme.colors.text;
31
+ const primary = theme.colors.primary;
32
+
33
+ if (isLoading) {
34
+ return (
35
+ <ConfirmationStage>
36
+ <Loading label="Confirming your enrollment…" />
37
+ </ConfirmationStage>
38
+ );
39
+ }
40
+
41
+ if (error || !data) {
42
+ return (
43
+ <ConfirmationStage>
44
+ <p style={{ color: text }}>We couldn’t find your enrollment.</p>
45
+ </ConfirmationStage>
46
+ );
47
+ }
48
+
49
+ const confirmed = data.status === "confirmed";
50
+ const cover = course?.media?.find((m) => m.type === "image")?.url ?? course?.media?.[0]?.url;
51
+
52
+ return (
53
+ <ConfirmationStage>
54
+ <ConfirmationCard>
55
+ {/* ── Ceremony ── */}
56
+ <div style={{ padding: "38px 30px 26px", textAlign: "center" }}>
57
+ {confirmed ? <CheckSeal /> : <WarnSeal icon={<AlertTriangle size={34} color="#f59e0b" />} />}
58
+
59
+ <p
60
+ className="cf-rise"
61
+ style={{
62
+ margin: "22px 0 6px",
63
+ fontSize: 12,
64
+ fontWeight: 700,
65
+ letterSpacing: "0.22em",
66
+ textTransform: "uppercase",
67
+ color: confirmed ? primary : "#f59e0b",
68
+ animationDelay: "0.15s",
69
+ }}
70
+ >
71
+ {confirmed ? "Enrollment" : "Action needed"}
72
+ </p>
73
+ <h1 className="cf-rise" style={{ fontSize: 29, fontWeight: 800, lineHeight: 1.12, margin: "0 0 8px", animationDelay: "0.22s" }}>
74
+ {confirmed ? "You’re enrolled 🎓" : "Enrollment not completed"}
75
+ </h1>
76
+ <p className="cf-rise" style={{ fontSize: 15, color: alpha(text, 0.7), margin: "0 auto", maxWidth: 340, animationDelay: "0.3s" }}>
77
+ {confirmed
78
+ ? `Welcome aboard${data.firstName ? `, ${data.firstName}` : ""}! Your spot is secured.`
79
+ : `Your enrollment is ${data.status}. If you were charged, contact support and we’ll help.`}
80
+ </p>
81
+ </div>
82
+
83
+ {/* ── Course card ── */}
84
+ {course && (
85
+ <div className="cf-rise" style={{ padding: "0 30px 24px", animationDelay: "0.36s" }}>
86
+ <div
87
+ style={{
88
+ display: "flex",
89
+ alignItems: "center",
90
+ gap: 12,
91
+ borderRadius: theme.cornerRadius,
92
+ border: `1px solid ${alpha(primary, 0.18)}`,
93
+ background: alpha(primary, 0.04),
94
+ padding: 12,
95
+ }}
96
+ >
97
+ {cover ? (
98
+ <img src={cover} alt={course.title} style={{ width: 56, height: 56, borderRadius: theme.cornerRadius, objectFit: "cover", flexShrink: 0 }} />
99
+ ) : (
100
+ <div style={{ width: 56, height: 56, borderRadius: theme.cornerRadius, background: alpha(primary, 0.12), display: "grid", placeItems: "center", flexShrink: 0 }}>
101
+ <GraduationCap size={26} color={primary} />
102
+ </div>
103
+ )}
104
+ <div style={{ minWidth: 0 }}>
105
+ <div style={{ fontSize: 16, fontWeight: 800 }}>{course.title}</div>
106
+ {course.modules?.length ? (
107
+ <div style={{ fontSize: 13, color: alpha(text, 0.6) }}>{course.modules.length} modules</div>
108
+ ) : null}
109
+ </div>
110
+ </div>
111
+ </div>
112
+ )}
113
+
114
+ <Perforation />
115
+
116
+ {/* ── Stub ── */}
117
+ <div className="cf-rise" style={{ padding: "26px 30px 8px", animationDelay: "0.44s" }}>
118
+ {confirmed && (
119
+ <div
120
+ style={{
121
+ display: "flex",
122
+ alignItems: "flex-start",
123
+ gap: 10,
124
+ fontSize: 14,
125
+ color: alpha(text, 0.8),
126
+ background: alpha(primary, 0.05),
127
+ border: `1px solid ${alpha(primary, 0.16)}`,
128
+ borderRadius: theme.cornerRadius,
129
+ padding: "12px 14px",
130
+ marginBottom: 16,
131
+ }}
132
+ >
133
+ <Mail size={16} color={primary} style={{ marginTop: 2, flexShrink: 0 }} />
134
+ <span style={{ lineHeight: 1.5 }}>
135
+ We’ve emailed a link to <strong style={{ color: text, wordBreak: "break-word" }}>{data.email}</strong> so you can
136
+ start learning.
137
+ </span>
138
+ </div>
139
+ )}
140
+ <dl style={{ display: "grid", gridTemplateColumns: "1fr auto", rowGap: 12, columnGap: 16, margin: 0 }}>
141
+ <ConfirmationRow label="Student" value={`${data.firstName} ${data.lastName}`.trim() || "—"} />
142
+ <ConfirmationRow label="Reference" value={`#${data.id.slice(0, 8).toUpperCase()}`} mono />
143
+ {Number(data.taxAmount || 0) > 0 && !data.taxInclusive && (
144
+ <ConfirmationRow label="Tax" value={fmt(Number(data.taxAmount), data.chargedCurrency || undefined)} />
145
+ )}
146
+ {/* The exact charged total (charged currency) when present — never a re-converted catalog amount. */}
147
+ <ConfirmationRow
148
+ label="Paid"
149
+ value={
150
+ Number(data.totalAmountInChargedCurrency ?? data.totalAmount) > 0
151
+ ? data.totalAmountInChargedCurrency
152
+ ? fmt(Number(data.totalAmountInChargedCurrency), data.chargedCurrency || undefined)
153
+ : fmt(data.totalAmount)
154
+ : "Free"
155
+ }
156
+ accent
157
+ strong
158
+ />
159
+ </dl>
160
+ {Number(data.taxAmount || 0) > 0 && data.taxInclusive && (
161
+ <div style={{ textAlign: "right", fontSize: 12, color: alpha(text, 0.6), marginTop: 6 }}>
162
+ Includes {fmt(Number(data.taxAmount), data.chargedCurrency || undefined)} tax
163
+ </div>
164
+ )}
165
+ </div>
166
+
167
+ {/* ── Action ── */}
168
+ <div className="cf-rise" style={{ padding: "20px 30px 30px", animationDelay: "0.52s" }}>
169
+ <a
170
+ href={explorePath}
171
+ style={{
172
+ display: "inline-flex",
173
+ alignItems: "center",
174
+ justifyContent: "center",
175
+ gap: 8,
176
+ width: "100%",
177
+ padding: "13px 18px",
178
+ borderRadius: theme.cornerRadius,
179
+ background: primary,
180
+ color: readableTextOn(primary),
181
+ fontWeight: 700,
182
+ textDecoration: "none",
183
+ }}
184
+ >
185
+ Browse more courses <ArrowRight size={16} />
186
+ </a>
187
+ </div>
188
+ </ConfirmationCard>
189
+ </ConfirmationStage>
190
+ );
191
+ }