@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,101 @@
1
+ import { useFeaturedBlogPost, useBlogPosts } from "../../data/queries/useBlog";
2
+ import type { BlogPost } from "../../types/models";
3
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
+ import { Loading } from "./Loading";
5
+
6
+ export interface BlogListProps {
7
+ /** Build the href for a blog post detail page from its slug. */
8
+ blogPostHref?: (slug: string) => string;
9
+ }
10
+
11
+ /** Short date only, e.g. "Jun 30, 2026". */
12
+ function formatDate(value: string | number | Date): string {
13
+ return new Date(value).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
14
+ }
15
+
16
+ function excerpt(html: string, len: number) {
17
+ return html.replace(/<[^>]+>/g, "").slice(0, len);
18
+ }
19
+
20
+ /** Blog index — featured post + grid of remaining posts. Built on `useFeaturedBlogPost` + `useBlogPosts`. */
21
+ export function BlogList({ blogPostHref = (slug) => `/i/blog/${slug}` }: BlogListProps = {}) {
22
+ const theme = useForgeTheme();
23
+ const { data: featured, isLoading: loadingFeatured } = useFeaturedBlogPost();
24
+ const { data, isLoading: loadingPosts } = useBlogPosts({ page: 1, limit: 12 });
25
+
26
+ if (loadingFeatured || loadingPosts) return <Loading fullPage />;
27
+
28
+ const posts = data?.data ?? [];
29
+ const regular = featured ? posts.filter((p) => p.id !== featured.id) : posts;
30
+
31
+ const card: React.CSSProperties = {
32
+ color: theme.colors.text,
33
+ textDecoration: "none",
34
+ border: `1px solid ${theme.colors.text}1a`,
35
+ borderRadius: theme.cornerRadius,
36
+ overflow: "hidden",
37
+ display: "block",
38
+ };
39
+
40
+ return (
41
+ <div style={{ maxWidth: 900, margin: "0 auto", color: theme.colors.text }}>
42
+ <h1 style={{ fontSize: 32, fontWeight: 800, marginBottom: 24 }}>Blog</h1>
43
+
44
+ {featured && (
45
+ <a href={blogPostHref(featured.slug)} style={{ ...card, marginBottom: 32 }}>
46
+ {featured.media?.[0]?.url && (
47
+ <img
48
+ src={featured.media[0].url}
49
+ alt={featured.title}
50
+ style={{ width: "100%", aspectRatio: "16/9", objectFit: "cover" }}
51
+ />
52
+ )}
53
+ <div style={{ padding: 24 }}>
54
+ <div style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: 8 }}>
55
+ <span
56
+ style={{
57
+ fontSize: 12,
58
+ padding: "2px 8px",
59
+ borderRadius: 4,
60
+ background: theme.colors.primary,
61
+ color: theme.colors.background,
62
+ }}
63
+ >
64
+ Featured
65
+ </span>
66
+ {featured.publishedAt && (
67
+ <span style={{ fontSize: 13, opacity: 0.6 }}>{formatDate(featured.publishedAt)}</span>
68
+ )}
69
+ </div>
70
+ <h2 style={{ fontSize: 26, fontWeight: 700, marginBottom: 8 }}>{featured.title}</h2>
71
+ <p style={{ opacity: 0.7 }}>{excerpt(featured.content, 200)}…</p>
72
+ </div>
73
+ </a>
74
+ )}
75
+
76
+ {regular.length ? (
77
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: 20 }}>
78
+ {regular.map((post: BlogPost) => {
79
+ const cover = post.media?.[0]?.url;
80
+ return (
81
+ <a key={post.id} href={blogPostHref(post.slug)} style={card}>
82
+ {cover && (
83
+ <img src={cover} alt={post.title} style={{ width: "100%", aspectRatio: "16/9", objectFit: "cover" }} />
84
+ )}
85
+ <div style={{ padding: 16 }}>
86
+ {post.publishedAt && (
87
+ <span style={{ fontSize: 12, opacity: 0.6 }}>{formatDate(post.publishedAt)}</span>
88
+ )}
89
+ <h3 style={{ fontSize: 18, fontWeight: 700, margin: "6px 0" }}>{post.title}</h3>
90
+ <p style={{ fontSize: 14, opacity: 0.7 }}>{excerpt(post.content, 120)}…</p>
91
+ </div>
92
+ </a>
93
+ );
94
+ })}
95
+ </div>
96
+ ) : (
97
+ !featured && <p style={{ opacity: 0.7 }}>No blog posts available yet.</p>
98
+ )}
99
+ </div>
100
+ );
101
+ }
@@ -0,0 +1,160 @@
1
+ import { Play, Pause } from "lucide-react";
2
+ import { useBlogPost } from "../../data/queries/useBlog";
3
+ import { useAudioPlayer } from "../../contexts/AudioPlayerContext";
4
+ import type { BlogPost as BlogPostModel } from "../../types/models";
5
+ import { useForgeTheme, useThemeTokens } from "../theme/ForgeThemeProvider";
6
+ import { Loading } from "./Loading";
7
+ // Baseline `.rich-text` content styles ship with Forge (WYSIWYG with the admin
8
+ // editor) — no host-loaded stylesheet needed.
9
+ import "./rich-text.css";
10
+
11
+ export interface BlogPostProps {
12
+ /** Post slug — the public API resolves the post by slug. */
13
+ slug: string;
14
+ /** Href for the "Back to Blog" link. */
15
+ blogHref?: string;
16
+ /** Server-fetched post to seed the query for SSR (no client loading flash). */
17
+ initialPost?: BlogPostModel | null;
18
+ }
19
+
20
+ /** Short date only, e.g. "Jun 30, 2026". */
21
+ function formatDate(value: string | number | Date): string {
22
+ return new Date(value).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
23
+ }
24
+
25
+ /** "m:ss" / "h:mm:ss" from seconds; empty string when unknown. */
26
+ function formatDuration(seconds: number | null | undefined): string {
27
+ if (!seconds || seconds <= 0) return "";
28
+ const h = Math.floor(seconds / 3600);
29
+ const m = Math.floor((seconds % 3600) / 60);
30
+ const s = Math.floor(seconds % 60);
31
+ const pad = (n: number) => n.toString().padStart(2, "0");
32
+ return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
33
+ }
34
+
35
+ /**
36
+ * Play/pause bar for a post's audio attachment (audio posts / podcast-style
37
+ * episodes). Plays through the shared Forge audio player, so playback persists
38
+ * in the global `<AudioPlayer />` bar while browsing.
39
+ */
40
+ function AudioAttachmentBar({
41
+ post,
42
+ audio,
43
+ coverImage,
44
+ }: {
45
+ post: BlogPostModel;
46
+ audio: BlogPostModel["media"][number];
47
+ coverImage?: string;
48
+ }) {
49
+ const t = useThemeTokens();
50
+ const player = useAudioPlayer();
51
+
52
+ const isCurrent = player.currentTrack?.id === post.id;
53
+ const isPlaying = isCurrent && player.isPlaying;
54
+ const duration = formatDuration(audio.durationSec);
55
+
56
+ const onClick = () => {
57
+ if (!audio.url) return;
58
+ if (isCurrent) {
59
+ if (isPlaying) player.pause();
60
+ else player.play();
61
+ } else {
62
+ player.loadAndPlay({ id: post.id, title: post.title, url: audio.url, coverImage });
63
+ }
64
+ };
65
+
66
+ return (
67
+ <div
68
+ style={{
69
+ display: "flex",
70
+ alignItems: "center",
71
+ gap: 16,
72
+ padding: 16,
73
+ marginBottom: 24,
74
+ border: `1px solid ${t.border}`,
75
+ borderRadius: t.cornerRadius,
76
+ background: t.surface,
77
+ }}
78
+ >
79
+ <button
80
+ type="button"
81
+ onClick={onClick}
82
+ aria-label={isPlaying ? "Pause" : "Play"}
83
+ style={{
84
+ display: "inline-flex",
85
+ alignItems: "center",
86
+ gap: 8,
87
+ padding: "10px 20px",
88
+ borderRadius: 999,
89
+ border: "none",
90
+ cursor: "pointer",
91
+ fontWeight: 600,
92
+ background: t.primary,
93
+ color: t.textPrimary,
94
+ }}
95
+ >
96
+ {isPlaying ? <Pause size={18} /> : <Play size={18} />}
97
+ {isPlaying ? "Pause" : "Play"}
98
+ </button>
99
+ {duration && <span style={{ fontSize: 14, opacity: 0.6, fontVariantNumeric: "tabular-nums" }}>{duration}</span>}
100
+ </div>
101
+ );
102
+ }
103
+
104
+ /** Blog post detail, resolved by slug. Built on `useBlogPost`. */
105
+ export function BlogPost({ slug, blogHref = "/i/blog", initialPost }: BlogPostProps) {
106
+ const theme = useForgeTheme();
107
+ // Detail is resolved by slug (the public API looks posts up by slug).
108
+ const { data: post, isLoading } = useBlogPost(slug, { initialData: initialPost });
109
+
110
+ if (isLoading) return <Loading fullPage />;
111
+ if (!post) return <p>Post not found.</p>;
112
+
113
+ // Cover is the first image media — audio posts put the audio file first in
114
+ // `media`, which must not be rendered as an <img>. `type` may be a bare kind
115
+ // ("image") or a mime ("image/jpeg"), so match by prefix.
116
+ const cover = post.media?.find((m) => (m.type || "").startsWith("image"))?.url;
117
+ // An audio attachment (audio posts / podcast-style episodes) gets a play bar.
118
+ const audio = post.media?.find((m) => (m.type || "").startsWith("audio"));
119
+
120
+ return (
121
+ <article style={{ maxWidth: 720, margin: "0 auto", color: theme.colors.text }}>
122
+ <a href={blogHref} style={{ color: theme.colors.primary, fontSize: 14 }}>
123
+ ← Back to Blog
124
+ </a>
125
+
126
+ {cover && (
127
+ <img
128
+ src={cover}
129
+ alt={post.title}
130
+ style={{ width: "100%", borderRadius: theme.cornerRadius, margin: "24px 0", objectFit: "cover" }}
131
+ />
132
+ )}
133
+
134
+ <div style={{ display: "flex", gap: 12, alignItems: "center", margin: "16px 0" }}>
135
+ {post.publishedAt && <span style={{ fontSize: 14, opacity: 0.6 }}>{formatDate(post.publishedAt)}</span>}
136
+ {post.isFeatured && (
137
+ <span
138
+ style={{
139
+ fontSize: 12,
140
+ padding: "2px 8px",
141
+ borderRadius: 4,
142
+ background: theme.colors.primary,
143
+ color: theme.colors.background,
144
+ }}
145
+ >
146
+ Featured
147
+ </span>
148
+ )}
149
+ </div>
150
+
151
+ <h1 style={{ fontSize: 34, fontWeight: 800, marginBottom: 24 }}>{post.title}</h1>
152
+
153
+ {audio && <AudioAttachmentBar post={post} audio={audio} coverImage={cover} />}
154
+
155
+ {/* `rich-text` = shared baseline content styles (WYSIWYG with the editor),
156
+ bundled with Forge via the ./rich-text.css import above. */}
157
+ <div className="rich-text" dangerouslySetInnerHTML={{ __html: post.content || "" }} />
158
+ </article>
159
+ );
160
+ }
@@ -0,0 +1,33 @@
1
+ import { type CSSProperties } from "react";
2
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
+ import { PageActions } from "./PageActions";
4
+ import { BlogComments } from "./BlogComments";
5
+
6
+ export interface BlogPostFooterProps {
7
+ blogPostId: string;
8
+ /** Where "Sign in to comment" links when the profile requires sign-in. */
9
+ signInHref?: string;
10
+ className?: string;
11
+ style?: CSSProperties;
12
+ }
13
+
14
+ /**
15
+ * The single drop-in for everything rendered after a blog post's body — the
16
+ * artist's configured page actions (CTAs) followed by the comments section.
17
+ * Owns its own spacing/divider so BOTH the customer app and site-starter (and
18
+ * AI-built sites) mount exactly this one component with no per-app wrappers,
19
+ * guaranteeing identical layout.
20
+ */
21
+ export function BlogPostFooter({ blogPostId, signInHref, className, style }: BlogPostFooterProps) {
22
+ const theme = useForgeTheme();
23
+ const border = theme.colors.border ?? `${theme.colors.primary}33`;
24
+
25
+ return (
26
+ <div className={className} style={{ marginTop: 48, textAlign: "left", ...style }}>
27
+ <PageActions pageType="blog_post" entityId={blogPostId} />
28
+ <div style={{ marginTop: 48, paddingTop: 32, borderTop: `1px solid ${border}` }}>
29
+ <BlogComments blogPostId={blogPostId} signInHref={signInHref} />
30
+ </div>
31
+ </div>
32
+ );
33
+ }
@@ -0,0 +1,101 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useForge } from "../../provider/ForgeProvider";
3
+ import { useSiteConfig } from "../../data/queries/useWebsite";
4
+
5
+ export interface BotFields {
6
+ /** Altcha proof-of-work payload (base64), empty until solved / when disabled. */
7
+ botToken: string;
8
+ /** Honeypot value — always "" for a human; any value marks a bot. */
9
+ botField: string;
10
+ }
11
+
12
+ export const EMPTY_BOT_FIELDS: BotFields = { botToken: "", botField: "" };
13
+
14
+ type Challenge = {
15
+ algorithm: string;
16
+ challenge: string;
17
+ salt: string;
18
+ signature: string;
19
+ maxnumber: number;
20
+ };
21
+
22
+ // Brute-force the proof-of-work: find n where SHA-256(salt + n) === challenge.
23
+ async function solvePow(salt: string, target: string, maxnumber: number): Promise<number | null> {
24
+ const enc = new TextEncoder();
25
+ for (let n = 0; n <= maxnumber; n++) {
26
+ const digest = await crypto.subtle.digest("SHA-256", enc.encode(salt + n));
27
+ const hex = Array.from(new Uint8Array(digest))
28
+ .map((b) => b.toString(16).padStart(2, "0"))
29
+ .join("");
30
+ if (hex === target) return n;
31
+ }
32
+ return null;
33
+ }
34
+
35
+ /**
36
+ * Drop-in bot protection for public forms — a hidden honeypot plus (when the
37
+ * platform has Altcha enabled) an invisible proof-of-work challenge solved in
38
+ * the browser. Reports the fields up via `onChange`; spread them into the form's
39
+ * submit body ({ botToken, botField }). SSR-safe (the PoW runs client-only). One
40
+ * self-hosted verifier works across every tenant subdomain + custom domain — no
41
+ * per-domain config, no external service.
42
+ */
43
+ export function BotProtection({ onChange }: { onChange: (fields: BotFields) => void }) {
44
+ const { apiUrl } = useForge();
45
+ const { data: siteConfig } = useSiteConfig();
46
+ const altchaEnabled = siteConfig?.altchaEnabled ?? false;
47
+
48
+ const [token, setToken] = useState("");
49
+ const [honeypot, setHoneypot] = useState("");
50
+
51
+ useEffect(() => {
52
+ onChange({ botToken: token, botField: honeypot });
53
+ }, [token, honeypot, onChange]);
54
+
55
+ useEffect(() => {
56
+ if (!altchaEnabled || typeof window === "undefined" || !apiUrl) return;
57
+ let cancelled = false;
58
+ (async () => {
59
+ try {
60
+ const res = await fetch(`${apiUrl.replace(/\/$/, "")}/public/altcha/challenge`);
61
+ if (!res.ok) return;
62
+ const ch = (await res.json()) as Partial<Challenge>;
63
+ if (!ch.challenge || !ch.salt || !ch.signature || ch.maxnumber == null) return; // disabled
64
+ const number = await solvePow(ch.salt, ch.challenge, ch.maxnumber);
65
+ if (cancelled || number == null) return;
66
+ const payload = btoa(
67
+ JSON.stringify({
68
+ algorithm: ch.algorithm,
69
+ challenge: ch.challenge,
70
+ number,
71
+ salt: ch.salt,
72
+ signature: ch.signature,
73
+ }),
74
+ );
75
+ setToken(payload);
76
+ } catch {
77
+ // Solve failures leave botToken empty; the server decides whether to
78
+ // enforce. Never block form render on bot protection.
79
+ }
80
+ })();
81
+ return () => {
82
+ cancelled = true;
83
+ };
84
+ }, [altchaEnabled, apiUrl]);
85
+
86
+ return (
87
+ <>
88
+ {/* Honeypot — off-screen, not announced, skipped by keyboard/autofill. */}
89
+ <input
90
+ type="text"
91
+ name="botField"
92
+ tabIndex={-1}
93
+ autoComplete="off"
94
+ aria-hidden="true"
95
+ value={honeypot}
96
+ onChange={(e) => setHoneypot(e.target.value)}
97
+ style={{ position: "absolute", left: "-9999px", top: "auto", width: 1, height: 1, opacity: 0 }}
98
+ />
99
+ </>
100
+ );
101
+ }
@@ -0,0 +1,88 @@
1
+ import { type ButtonHTMLAttributes, type CSSProperties, forwardRef } from "react";
2
+ import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
3
+ import { readableTextOn } from "../theme/contrast";
4
+
5
+ export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost";
6
+ export type ButtonSize = "sm" | "md" | "lg";
7
+
8
+ const alpha = (hex: string, a: number) => hex + Math.round(a * 255).toString(16).padStart(2, "0");
9
+
10
+ const SIZES: Record<ButtonSize, { padding: string; fontSize: number }> = {
11
+ sm: { padding: "8px 14px", fontSize: 14 },
12
+ md: { padding: "12px 18px", fontSize: 15 },
13
+ lg: { padding: "14px 22px", fontSize: 16 },
14
+ };
15
+
16
+ /**
17
+ * The style object for a themed button — exported so existing styled blocks can
18
+ * adopt it without swapping their markup. Primary buttons use a contrast-aware
19
+ * text color (never a hardcoded #fff on the tenant primary).
20
+ */
21
+ export function buttonStyle(
22
+ t: ForgeTheme,
23
+ opts: { variant?: ButtonVariant; size?: ButtonSize; fullWidth?: boolean; disabled?: boolean } = {},
24
+ ): CSSProperties {
25
+ const { variant = "primary", size = "md", fullWidth, disabled } = opts;
26
+ const s = SIZES[size];
27
+ const base: CSSProperties = {
28
+ display: "inline-flex",
29
+ alignItems: "center",
30
+ justifyContent: "center",
31
+ gap: 8,
32
+ width: fullWidth ? "100%" : undefined,
33
+ padding: s.padding,
34
+ fontSize: s.fontSize,
35
+ fontWeight: 600,
36
+ lineHeight: 1.2,
37
+ borderRadius: t.cornerRadius,
38
+ fontFamily: t.fontFamily,
39
+ cursor: disabled ? "not-allowed" : "pointer",
40
+ opacity: disabled ? 0.6 : 1,
41
+ transition: "opacity .15s ease, background .15s ease",
42
+ textDecoration: "none",
43
+ boxSizing: "border-box",
44
+ };
45
+ switch (variant) {
46
+ case "secondary":
47
+ return { ...base, background: alpha(t.colors.primary, 0.12), color: t.colors.text, border: "none" };
48
+ case "outline":
49
+ return { ...base, background: "transparent", color: t.colors.text, border: `1px solid ${alpha(t.colors.primary, 0.5)}` };
50
+ case "ghost":
51
+ return { ...base, background: "transparent", color: t.colors.primary, border: "none" };
52
+ case "primary":
53
+ default:
54
+ // Creator's chosen "text on primary" wins; auto-contrast is the fallback.
55
+ return {
56
+ ...base,
57
+ background: t.colors.primary,
58
+ color: t.colors.textPrimary || readableTextOn(t.colors.primary),
59
+ border: "none",
60
+ };
61
+ }
62
+ }
63
+
64
+ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
65
+ variant?: ButtonVariant;
66
+ size?: ButtonSize;
67
+ fullWidth?: boolean;
68
+ loading?: boolean;
69
+ }
70
+
71
+ /** Unified themed button — drop-in from `@tribe-nest/forge/ui`. Contrast-safe. */
72
+ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
73
+ { variant, size, fullWidth, loading, disabled, style, children, ...rest },
74
+ ref,
75
+ ) {
76
+ const theme = useForgeTheme();
77
+ const isDisabled = disabled || loading;
78
+ return (
79
+ <button
80
+ ref={ref}
81
+ disabled={isDisabled}
82
+ style={{ ...buttonStyle(theme, { variant, size, fullWidth, disabled: isDisabled }), ...style }}
83
+ {...rest}
84
+ >
85
+ {loading ? "…" : children}
86
+ </button>
87
+ );
88
+ });