@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,140 @@
1
+ import type { PublicCourse } from "../../types/models";
2
+ import { useGetCourse } from "../../data/queries/useCourses";
3
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
+ import { useAmountFormatter } from "../format/useFormatCurrency";
5
+ import { PriceDisplay } from "../format/PriceDisplay";
6
+ import { usePricesIncludeTax } from "../../data/queries/useWebsite";
7
+ import { Loading } from "./Loading";
8
+ import { CourseCheckout } from "./CourseCheckout";
9
+ import { ReviewsSection } from "./ReviewsSection";
10
+
11
+ export interface CourseDetailProps {
12
+ /** Course slug (resolves the course). */
13
+ slug?: string;
14
+ /** Format a numeric amount for display. */
15
+ formatAmount?: (amount: number) => string;
16
+ /** Extra class(es) appended to the root element. */
17
+ className?: string;
18
+ /** Inline style merged LAST into the root element (callers can override). */
19
+ style?: React.CSSProperties;
20
+ /** SSR-hydrated course, used as React Query initial data. */
21
+ initialCourse?: PublicCourse;
22
+ /** Stripe return URL for a PAID enrollment (forwarded to the enroll flow). */
23
+ finalisePath?: (slug: string, bookingId: string) => string;
24
+ /** Host-owned navigation on FREE completion (forwarded to the enroll flow). */
25
+ onComplete?: (info: { slug: string; bookingId: string }) => void;
26
+ }
27
+
28
+ export function CourseDetail({
29
+ slug,
30
+ formatAmount,
31
+ className,
32
+ style,
33
+ initialCourse,
34
+ finalisePath,
35
+ onComplete,
36
+ }: CourseDetailProps) {
37
+ const t = useThemeTokens();
38
+ const fmt = useAmountFormatter(formatAmount);
39
+ const pricesIncludeTax = usePricesIncludeTax();
40
+ const { data: course, isLoading } = useGetCourse(slug, { initialData: initialCourse });
41
+
42
+ if (isLoading) return <Loading fullPage />;
43
+ if (!course) return <p>Course not found.</p>;
44
+
45
+ const cover = course.media?.find((m) => m.type === "image")?.url ?? course.media?.[0]?.url;
46
+ const introVideo = course.media?.find((m) => m.type === "video")?.url;
47
+ const modules = course.modules ?? [];
48
+
49
+ return (
50
+ <div
51
+ className={`pb-32 md:pb-0${className ? ` ${className}` : ""}`}
52
+ style={{ maxWidth: 1040, margin: "0 auto", color: t.text, ...style }}
53
+ >
54
+ {introVideo ? (
55
+ <video
56
+ src={introVideo}
57
+ controls
58
+ style={{ width: "100%", aspectRatio: "16/9", objectFit: "cover", borderRadius: t.cornerRadius }}
59
+ />
60
+ ) : (
61
+ cover && (
62
+ <img
63
+ src={cover}
64
+ alt={course.title}
65
+ style={{ width: "100%", aspectRatio: "16/9", objectFit: "cover", borderRadius: t.cornerRadius }}
66
+ />
67
+ )
68
+ )}
69
+
70
+ <div className="flex flex-col md:flex-row md:items-start gap-6 mt-6">
71
+ <div className="flex-1 min-w-0">
72
+ <h1 style={{ fontSize: 32, fontWeight: 800, margin: "0 0 12px", fontFamily: t.headingFontFamily }}>{course.title}</h1>
73
+
74
+ {course.description && (
75
+ <div dangerouslySetInnerHTML={{ __html: course.description }} style={{ lineHeight: 1.7, opacity: 0.9 }} />
76
+ )}
77
+
78
+ {modules.length > 0 && (
79
+ <>
80
+ <h2 style={{ fontSize: 20, fontWeight: 700, margin: "24px 0 12px", fontFamily: t.headingFontFamily }}>Course Content</h2>
81
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
82
+ {modules.map((module) => (
83
+ <div
84
+ key={module.id}
85
+ style={{
86
+ padding: 16,
87
+ border: `1px solid ${t.primary}40`,
88
+ borderRadius: t.cornerRadius,
89
+ }}
90
+ >
91
+ <h3 style={{ fontSize: 17, fontWeight: 600, marginBottom: module.description ? 6 : 0 }}>
92
+ {module.title}
93
+ </h3>
94
+ {module.description && <p style={{ opacity: 0.7, marginBottom: 8 }}>{module.description}</p>}
95
+ {module.lessons && module.lessons.length > 0 && (
96
+ <ul style={{ listStyle: "disc", paddingLeft: 20, display: "flex", flexDirection: "column", gap: 4 }}>
97
+ {module.lessons.map((lesson) => (
98
+ <li key={lesson.id} style={{ opacity: 0.8 }}>
99
+ {lesson.title}
100
+ </li>
101
+ ))}
102
+ </ul>
103
+ )}
104
+ </div>
105
+ ))}
106
+ </div>
107
+ </>
108
+ )}
109
+ </div>
110
+
111
+ <aside
112
+ className="fixed bottom-0 left-0 right-0 z-40 w-full md:sticky md:top-4 md:bottom-auto md:left-auto md:right-auto md:w-[300px] md:shrink-0 flex flex-col gap-3"
113
+ style={{
114
+ padding: 16,
115
+ border: `1px solid ${t.primary}80`,
116
+ borderRadius: t.cornerRadius,
117
+ background: t.surface,
118
+ }}
119
+ >
120
+ <span style={{ fontWeight: 700, fontSize: 22, color: t.primary }}>
121
+ {course.compareAtPrice != null && Number(course.compareAtPrice) > Number(course.price) && (
122
+ <span style={{ textDecoration: "line-through", opacity: 0.6, marginRight: 8, fontWeight: 400, fontSize: 16 }}>
123
+ {fmt(Number(course.compareAtPrice))}
124
+ </span>
125
+ )}
126
+ {Number(course.price) <= 0 ? (
127
+ "Free"
128
+ ) : (
129
+ <PriceDisplay amount={Number(course.price)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
130
+ )}
131
+ </span>
132
+ <CourseCheckout slug={slug} formatAmount={fmt} finalisePath={finalisePath} onComplete={onComplete} />
133
+ </aside>
134
+ </div>
135
+
136
+ {/* Verified-purchase reviews */}
137
+ <ReviewsSection entityType="course" entityId={course.id} initialAggregate={course.reviewAggregate} />
138
+ </div>
139
+ );
140
+ }
@@ -0,0 +1,85 @@
1
+ import { useGetCourses } from "../../data/queries/useCourses";
2
+ import type { PublicCourse } from "../../types/models";
3
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
+ import { useAmountFormatter } from "../format/useFormatCurrency";
5
+ import { PriceDisplay } from "../format/PriceDisplay";
6
+ import { usePricesIncludeTax } from "../../data/queries/useWebsite";
7
+ import { Loading } from "./Loading";
8
+
9
+ export interface CoursesGridProps {
10
+ columns?: number;
11
+ limit?: number;
12
+ formatAmount?: (amount: number) => string;
13
+ onSelect?: (course: PublicCourse) => void;
14
+ className?: string;
15
+ style?: React.CSSProperties;
16
+ }
17
+
18
+ /** Themed courses grid, built on `useGetCourses`. */
19
+ export function CoursesGrid({ columns = 3, limit, formatAmount, onSelect, className, style }: CoursesGridProps) {
20
+ const t = useThemeTokens();
21
+ const fmt = useAmountFormatter(formatAmount);
22
+ const pricesIncludeTax = usePricesIncludeTax();
23
+ const { data, isLoading } = useGetCourses({ page: 1 });
24
+ const courses = (data?.data ?? []).slice(0, limit ?? 100);
25
+
26
+ if (isLoading) return <Loading />;
27
+ if (!courses.length) return <p style={{ color: t.text, opacity: 0.7 }}>No courses yet.</p>;
28
+
29
+ const gap = 16;
30
+ const track = `minmax(max(150px, calc((100% - ${(columns - 1) * gap}px) / ${columns})), 1fr)`;
31
+
32
+ return (
33
+ <div
34
+ className={className}
35
+ style={{
36
+ display: "grid",
37
+ gridTemplateColumns: `repeat(auto-fill, ${track})`,
38
+ gap,
39
+ fontFamily: t.fontFamily,
40
+ ...style,
41
+ }}
42
+ >
43
+ {courses.map((course) => {
44
+ const cover = course.media?.find((m) => m.type === "image")?.url ?? course.media?.[0]?.url;
45
+ const price = Number(course.price) || 0;
46
+ const compareAt = course.compareAtPrice != null ? Number(course.compareAtPrice) : null;
47
+ return (
48
+ <button
49
+ key={course.id}
50
+ onClick={() => onSelect?.(course)}
51
+ style={{
52
+ textAlign: "left",
53
+ cursor: onSelect ? "pointer" : "default",
54
+ background: t.surface,
55
+ border: `1px solid ${t.primary}20`,
56
+ borderRadius: t.cornerRadius,
57
+ overflow: "hidden",
58
+ padding: 0,
59
+ color: t.text,
60
+ }}
61
+ >
62
+ <div style={{ aspectRatio: "16 / 9", background: `${t.primary}10` }}>
63
+ {cover && <img src={cover} alt={course.title} style={{ width: "100%", height: "100%", objectFit: "cover" }} />}
64
+ </div>
65
+ <div style={{ padding: 10 }}>
66
+ <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 2 }}>{course.title}</div>
67
+ <div style={{ color: t.primary, fontWeight: 600, fontSize: 14 }}>
68
+ {compareAt != null && compareAt > price && (
69
+ <span style={{ textDecoration: "line-through", opacity: 0.6, marginRight: 6, fontWeight: 400 }}>
70
+ {fmt(compareAt)}
71
+ </span>
72
+ )}
73
+ {price > 0 ? (
74
+ <PriceDisplay amount={price} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
75
+ ) : (
76
+ "Free"
77
+ )}
78
+ </div>
79
+ </div>
80
+ </button>
81
+ );
82
+ })}
83
+ </div>
84
+ );
85
+ }
@@ -0,0 +1,51 @@
1
+ import { usePublicAuth } from "../../contexts/PublicAuthContext";
2
+ import { currencySymbol, type Currency } from "../../types/models";
3
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
+
5
+ export interface CurrencySwitcherProps {
6
+ /** Extra class(es) on the select. */
7
+ className?: string;
8
+ /** Inline style merged into the select. */
9
+ style?: React.CSSProperties;
10
+ }
11
+
12
+ /**
13
+ * Drop-in currency switcher: a themed select of the tenant's supported
14
+ * currencies, bound to `usePublicAuth().userSelectedCurrency`. Picking one
15
+ * re-formats/converts every amount rendered via `useFormatCurrency`. Renders
16
+ * nothing when the site has no multi-currency setup. Drop it into a header:
17
+ * `<CurrencySwitcher />`.
18
+ */
19
+ export function CurrencySwitcher({ className, style }: CurrencySwitcherProps) {
20
+ const t = useThemeTokens();
21
+ const { currencies, userSelectedCurrency, setUserSelectedCurrency } = usePublicAuth();
22
+
23
+ if (!currencies || !currencies.supportedCurrencies?.length) return null;
24
+
25
+ return (
26
+ <select
27
+ className={className}
28
+ aria-label="Currency"
29
+ value={userSelectedCurrency ?? currencies.userCurrency ?? ""}
30
+ onChange={(e) => setUserSelectedCurrency(e.target.value as Currency)}
31
+ style={{
32
+ background: "transparent",
33
+ color: t.text,
34
+ border: `1px solid ${t.border}`,
35
+ borderRadius: t.cornerRadius,
36
+ padding: "6px 8px",
37
+ fontFamily: t.fontFamily,
38
+ fontSize: 14,
39
+ cursor: "pointer",
40
+ ...style,
41
+ }}
42
+ >
43
+ {currencies.supportedCurrencies.map((c) => (
44
+ <option key={c} value={c}>
45
+ {currencySymbol[c] ? `${currencySymbol[c]} ` : ""}
46
+ {c}
47
+ </option>
48
+ ))}
49
+ </select>
50
+ );
51
+ }
@@ -0,0 +1,247 @@
1
+ import type { ReactNode } from "react";
2
+ import { Donation, useDonationContext, type DonationPaymentRenderProps } from "../headless/donation";
3
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
+ import { Loading } from "./Loading";
5
+ import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
6
+ import { readableTextOn } from "../theme/contrast";
7
+ import { summarizeTaxQuote } from "../format/PriceDisplay";
8
+ import { useAmountFormatter } from "../format/useFormatCurrency";
9
+
10
+ export interface DonationButtonProps {
11
+ donationId: string;
12
+ /** Trigger label. Falls back to the donation's configured button text. */
13
+ text?: string;
14
+ returnUrl?: string;
15
+ onSuccess?: () => void;
16
+ /**
17
+ * Render the payment UI (Stripe Elements stay in the app — Forge owns only the
18
+ * donation data/flow). Optional if a <ForgePaymentProvider> is registered.
19
+ */
20
+ renderPayment?: (props: DonationPaymentRenderProps) => ReactNode;
21
+ /** Optional currency formatter; defaults to a plain `$0.00`. */
22
+ formatAmount?: (amount: number) => string;
23
+ }
24
+
25
+ /**
26
+ * One-line themed donation button + modal flow, built on the headless
27
+ * `<Donation>` compound. Respects the active `ForgeThemeProvider` tokens.
28
+ */
29
+ export function DonationButton({ donationId, text, returnUrl, onSuccess, renderPayment, formatAmount }: DonationButtonProps) {
30
+ const theme = useForgeTheme();
31
+ const registered = usePaymentRenderer();
32
+ const fmt = useAmountFormatter(formatAmount);
33
+ // Donations confirm in place, so default the registered renderer to "manual".
34
+ const renderPay =
35
+ renderPayment ?? (registered ? (p: DonationPaymentRenderProps) => registered({ ...p, mode: "manual" }) : undefined);
36
+
37
+ return (
38
+ <Donation donationId={donationId} returnUrl={returnUrl} onSuccess={onSuccess}>
39
+ <Donation.Trigger asChild>
40
+ <button
41
+ style={{
42
+ background: theme.colors.primary,
43
+ color: readableTextOn(theme.colors.primary),
44
+ border: "none",
45
+ borderRadius: theme.cornerRadius,
46
+ padding: "10px 18px",
47
+ fontWeight: 600,
48
+ cursor: "pointer",
49
+ fontFamily: theme.fontFamily,
50
+ }}
51
+ >
52
+ {text ?? "Donate"}
53
+ </button>
54
+ </Donation.Trigger>
55
+
56
+ <Donation.Modal
57
+ overlayStyle={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }}
58
+ style={{
59
+ position: "fixed",
60
+ top: "50%",
61
+ left: "50%",
62
+ transform: "translate(-50%, -50%)",
63
+ width: "min(480px, calc(100vw - 32px))",
64
+ maxHeight: "85vh",
65
+ overflowY: "auto",
66
+ background: theme.colors.background,
67
+ color: theme.colors.text,
68
+ borderRadius: theme.cornerRadius,
69
+ padding: 24,
70
+ zIndex: 51,
71
+ fontFamily: theme.fontFamily,
72
+ }}
73
+ >
74
+ <Donation.Title className="">{text ?? "Support"}</Donation.Title>
75
+ <DonationBody fmt={fmt} renderPayment={renderPay} />
76
+ </Donation.Modal>
77
+ </Donation>
78
+ );
79
+ }
80
+
81
+ function DonationBody({
82
+ fmt,
83
+ renderPayment,
84
+ }: {
85
+ fmt: (n: number) => string;
86
+ renderPayment?: (props: DonationPaymentRenderProps) => ReactNode;
87
+ }) {
88
+ const c = useDonationContext();
89
+ const theme = useForgeTheme();
90
+ // Authoritative tax quote from the donation start-payment (display only).
91
+ const taxSummary = summarizeTaxQuote(c.startResult?.taxQuote);
92
+ const inputStyle = {
93
+ width: "100%",
94
+ padding: 10,
95
+ border: `1px solid ${theme.colors.primary}40`,
96
+ borderRadius: theme.cornerRadius,
97
+ background: theme.colors.background,
98
+ color: theme.colors.text,
99
+ marginTop: 6,
100
+ } as const;
101
+
102
+ if (c.isLoading) return <Loading />;
103
+
104
+ // ── Result ──
105
+ if (c.step === "result") {
106
+ return (
107
+ <div style={{ textAlign: "center", padding: "16px 0" }}>
108
+ <h3 style={{ marginBottom: 8 }}>
109
+ {c.outcome === "succeeded" ? "Donation received" : "Payment failed"}
110
+ </h3>
111
+ <p style={{ opacity: 0.8 }}>
112
+ {c.outcome === "succeeded"
113
+ ? c.donation?.successMessage || "Thank you for your contribution."
114
+ : c.paymentErrorMessage || "Your card was not charged. Please try again."}
115
+ </p>
116
+ <div style={{ display: "flex", gap: 8, marginTop: 16, justifyContent: "center" }}>
117
+ {c.outcome === "failed" && (
118
+ <button onClick={c.reset} style={{ ...inputStyle, width: "auto", cursor: "pointer" }}>
119
+ Try again
120
+ </button>
121
+ )}
122
+ <Donation.Close asChild>
123
+ <button
124
+ style={{
125
+ background: theme.colors.primary,
126
+ color: readableTextOn(theme.colors.primary),
127
+ border: "none",
128
+ borderRadius: theme.cornerRadius,
129
+ padding: "10px 18px",
130
+ cursor: "pointer",
131
+ }}
132
+ >
133
+ Close
134
+ </button>
135
+ </Donation.Close>
136
+ </div>
137
+ </div>
138
+ );
139
+ }
140
+
141
+ // ── Payment ──
142
+ if (c.step === "payment") {
143
+ return (
144
+ <div style={{ marginTop: 16 }}>
145
+ <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12 }}>
146
+ <span>Total</span>
147
+ <strong style={{ color: theme.colors.primary, textAlign: "right" }}>
148
+ {fmt(c.startResult?.totalAmount ?? c.estimatedTotal)}
149
+ {taxSummary && (
150
+ <span style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}>
151
+ {taxSummary.mode === "inclusive" ? taxSummary.label : `incl. ${taxSummary.formattedTax} tax`}
152
+ </span>
153
+ )}
154
+ </strong>
155
+ </div>
156
+ {renderPayment ? (
157
+ <Donation.PaymentStep>{(p) => renderPayment(p)}</Donation.PaymentStep>
158
+ ) : (
159
+ <p style={{ opacity: 0.7 }}>Payment is not configured.</p>
160
+ )}
161
+ </div>
162
+ );
163
+ }
164
+
165
+ // ── Amount + donor ──
166
+ return (
167
+ <Donation.AmountStep>
168
+ <div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 12 }}>
169
+ {c.presets.length > 0 && (
170
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
171
+ {c.presets.map((amount) => {
172
+ const active = c.selectedPreset === amount && !c.customAmount;
173
+ return (
174
+ <button
175
+ key={amount}
176
+ type="button"
177
+ onClick={() => {
178
+ c.setSelectedPreset(amount);
179
+ c.setCustomAmount("");
180
+ }}
181
+ style={{
182
+ padding: "8px 14px",
183
+ border: `1px solid ${active ? theme.colors.primary : theme.colors.primary + "40"}`,
184
+ background: active ? theme.colors.primary + "1f" : theme.colors.background,
185
+ color: theme.colors.text,
186
+ borderRadius: theme.cornerRadius,
187
+ cursor: "pointer",
188
+ }}
189
+ >
190
+ {fmt(amount)}
191
+ </button>
192
+ );
193
+ })}
194
+ </div>
195
+ )}
196
+
197
+ <label>
198
+ Custom amount{c.minimum > 0 ? ` (min ${fmt(c.minimum)})` : ""}
199
+ <input
200
+ type="number"
201
+ min={c.minimum || 0}
202
+ step="0.01"
203
+ value={c.customAmount}
204
+ onChange={(e) => {
205
+ c.setCustomAmount(e.target.value);
206
+ c.setSelectedPreset(null);
207
+ }}
208
+ style={inputStyle}
209
+ />
210
+ </label>
211
+
212
+ {c.donation?.coverFeesEnabled && c.effectiveAmount > 0 && (
213
+ <label style={{ display: "flex", gap: 8, alignItems: "center" }}>
214
+ <input type="checkbox" checked={c.coverFees} onChange={(e) => c.setCoverFees(e.target.checked)} />
215
+ <span>Cover the {fmt(c.estimatedFee)} processing fee</span>
216
+ </label>
217
+ )}
218
+
219
+ <div style={{ display: "flex", gap: 8 }}>
220
+ <input placeholder="First name" value={c.firstName} onChange={(e) => c.setFirstName(e.target.value)} style={inputStyle} />
221
+ <input placeholder="Last name" value={c.lastName} onChange={(e) => c.setLastName(e.target.value)} style={inputStyle} />
222
+ </div>
223
+ <input placeholder="Email" type="email" value={c.email} onChange={(e) => c.setEmail(e.target.value)} style={inputStyle} />
224
+ <textarea placeholder="Message (optional)" value={c.message} onChange={(e) => c.setMessage(e.target.value)} rows={2} style={inputStyle} />
225
+
226
+ {c.startError && <p style={{ color: "#ef4444", fontSize: 14 }}>{c.startError}</p>}
227
+
228
+ <button
229
+ onClick={c.continueToPayment}
230
+ disabled={c.isStarting}
231
+ style={{
232
+ background: theme.colors.primary,
233
+ color: readableTextOn(theme.colors.primary),
234
+ border: "none",
235
+ borderRadius: theme.cornerRadius,
236
+ padding: "12px 18px",
237
+ fontWeight: 600,
238
+ cursor: "pointer",
239
+ opacity: c.isStarting ? 0.6 : 1,
240
+ }}
241
+ >
242
+ {c.isStarting ? "Processing…" : `Donate ${c.effectiveAmount > 0 ? fmt(c.estimatedTotal) : ""}`.trim()}
243
+ </button>
244
+ </div>
245
+ </Donation.AmountStep>
246
+ );
247
+ }
@@ -0,0 +1,29 @@
1
+ import { DonationButton } from "./DonationButton";
2
+ import { useAmountFormatter } from "../format/useFormatCurrency";
3
+
4
+ export interface DonationPageProps {
5
+ /** Donation id from the URL search param (`?id=`). */
6
+ donationId?: string;
7
+ /** Optional currency formatter passed through to the donation flow. */
8
+ formatAmount?: (amount: number) => string;
9
+ }
10
+
11
+ /** Standalone donation page — heading + copy wrapping the `DonationButton` flow. */
12
+ export function DonationPage({ donationId, formatAmount }: DonationPageProps) {
13
+ const fmt = useAmountFormatter(formatAmount);
14
+ return (
15
+ <div style={{ maxWidth: 520, margin: "0 auto", textAlign: "center" }}>
16
+ <h1 style={{ fontSize: 32, fontWeight: 800, marginBottom: 12 }}>Support the work</h1>
17
+ <p style={{ opacity: 0.75, marginBottom: 24 }}>
18
+ Your contribution keeps this going. Thank you.
19
+ </p>
20
+ {donationId ? (
21
+ <DonationButton donationId={donationId} formatAmount={fmt} />
22
+ ) : (
23
+ <p style={{ opacity: 0.6 }}>
24
+ Add a donation id to the URL, e.g. <code>/i/donation?id=&lt;donationId&gt;</code>.
25
+ </p>
26
+ )}
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,88 @@
1
+ import { useEmailListForm } from "../headless/forms/useEmailListForm";
2
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
+ import { readableTextOn } from "../theme/contrast";
4
+
5
+ export interface EmailListFormProps {
6
+ emailListId?: string;
7
+ leadMagnetId?: string;
8
+ includeName?: boolean;
9
+ includePhoneNumber?: boolean;
10
+ title?: string;
11
+ description?: string;
12
+ buttonText?: string;
13
+ successMessage?: string;
14
+ }
15
+
16
+ /** One-line themed email-list signup, built on `useEmailListForm`. */
17
+ export function EmailListForm({
18
+ emailListId,
19
+ leadMagnetId,
20
+ includeName,
21
+ includePhoneNumber,
22
+ title = "Join my email list",
23
+ description,
24
+ buttonText = "Join",
25
+ successMessage = "Thanks for subscribing!",
26
+ }: EmailListFormProps) {
27
+ const theme = useForgeTheme();
28
+ const f = useEmailListForm({ emailListId, leadMagnetId, includeName, includePhoneNumber });
29
+
30
+ const inputStyle = {
31
+ width: "100%",
32
+ padding: 10,
33
+ border: `1px solid ${theme.colors.primary}40`,
34
+ borderRadius: theme.cornerRadius,
35
+ background: theme.colors.background,
36
+ color: theme.colors.text,
37
+ } as const;
38
+
39
+ if (f.isSuccess) {
40
+ return (
41
+ <div style={{ color: theme.colors.text, fontFamily: theme.fontFamily, textAlign: "center", padding: 8 }}>
42
+ {f.requiresConfirmation ? "Almost there — check your email to confirm." : successMessage}
43
+ </div>
44
+ );
45
+ }
46
+
47
+ return (
48
+ <div style={{ display: "flex", flexDirection: "column", gap: 10, color: theme.colors.text, fontFamily: theme.fontFamily }}>
49
+ {title && <h3 style={{ fontWeight: 600 }}>{title}</h3>}
50
+ {description && <p style={{ opacity: 0.8, fontSize: 14 }}>{description}</p>}
51
+ {includeName && (
52
+ <input placeholder="Your name" value={f.name} onChange={(e) => f.setName(e.target.value)} style={inputStyle} />
53
+ )}
54
+ <input
55
+ type="email"
56
+ placeholder="Email address"
57
+ value={f.email}
58
+ onChange={(e) => f.setEmail(e.target.value)}
59
+ style={inputStyle}
60
+ />
61
+ {includePhoneNumber && (
62
+ <input
63
+ placeholder="Phone number"
64
+ value={f.phoneNumber}
65
+ onChange={(e) => f.setPhoneNumber(e.target.value)}
66
+ style={inputStyle}
67
+ />
68
+ )}
69
+ {f.error && <p style={{ color: "#ef4444", fontSize: 14 }}>{f.error}</p>}
70
+ <button
71
+ onClick={f.submit}
72
+ disabled={f.isSubmitting}
73
+ style={{
74
+ background: theme.colors.primary,
75
+ color: readableTextOn(theme.colors.primary),
76
+ border: "none",
77
+ borderRadius: theme.cornerRadius,
78
+ padding: "10px 18px",
79
+ fontWeight: 600,
80
+ cursor: "pointer",
81
+ opacity: f.isSubmitting ? 0.6 : 1,
82
+ }}
83
+ >
84
+ {f.isSubmitting ? "Joining…" : buttonText}
85
+ </button>
86
+ </div>
87
+ );
88
+ }