@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,152 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
+ import { readableTextOn } from "../theme/contrast";
4
+ import { useAiAgent } from "../headless/agent/useAiAgent";
5
+ import { Markdown } from "./Markdown";
6
+
7
+ export interface AiAgentWidgetProps {
8
+ /** Corner to anchor the floating bubble. Default "bottom-right". */
9
+ position?: "bottom-right" | "bottom-left";
10
+ /** Accent color for the bubble + user bubbles. Defaults to the theme primary. */
11
+ accentColor?: string;
12
+ /** Header title. Defaults to the agent's configured display name. */
13
+ title?: string;
14
+ /** Stacking order for the floating elements. Default 9999. */
15
+ zIndex?: number;
16
+ }
17
+
18
+ /**
19
+ * Drop-in website AI agent. A self-contained floating chat widget: fetches its
20
+ * own config for the site's profile, renders a fixed-position bubble + panel,
21
+ * and streams answers grounded in the artist's content + knowledge base. Render
22
+ * it once (e.g. in __root); it renders nothing when the agent is disabled for
23
+ * the profile. Appearance-only props let authors restyle it without touching
24
+ * behavior. For custom UI, use the `useAiAgent()` hook instead.
25
+ */
26
+ export function AiAgentWidget({ position = "bottom-right", accentColor, title, zIndex = 9999 }: AiAgentWidgetProps) {
27
+ const theme = useForgeTheme();
28
+ const { isEnabled, config, messages, sending, error, send } = useAiAgent();
29
+ const [open, setOpen] = useState(false);
30
+ const [input, setInput] = useState("");
31
+ const scrollRef = useRef<HTMLDivElement>(null);
32
+
33
+ const accent = accentColor || theme.colors.primary;
34
+ const onAccent = readableTextOn(accent);
35
+ const side = position === "bottom-left" ? { left: 20 } : { right: 20 };
36
+ const heading = title || config?.displayName || "Assistant";
37
+
38
+ useEffect(() => {
39
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
40
+ }, [messages, sending]);
41
+
42
+ if (!isEnabled) return null;
43
+
44
+ const submit = () => {
45
+ if (!input.trim()) return;
46
+ send(input);
47
+ setInput("");
48
+ };
49
+
50
+ return (
51
+ <div style={{ position: "fixed", bottom: 20, zIndex, ...side }}>
52
+ {open && (
53
+ <div
54
+ style={{
55
+ width: "min(380px, calc(100vw - 40px))",
56
+ height: "min(560px, calc(100vh - 120px))",
57
+ marginBottom: 12,
58
+ display: "flex",
59
+ flexDirection: "column",
60
+ background: theme.colors.background,
61
+ color: theme.colors.text,
62
+ border: `1px solid ${theme.colors.text}1a`,
63
+ borderRadius: theme.cornerRadius,
64
+ overflow: "hidden",
65
+ boxShadow: "0 12px 40px rgba(0,0,0,0.18)",
66
+ }}
67
+ >
68
+ <div style={{ background: accent, color: onAccent, padding: "12px 16px", fontWeight: 600, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
69
+ <span>{heading}</span>
70
+ <button onClick={() => setOpen(false)} style={{ background: "transparent", border: "none", color: onAccent, cursor: "pointer", fontSize: 20, lineHeight: 1 }} aria-label="Close">
71
+ ×
72
+ </button>
73
+ </div>
74
+
75
+ <div ref={scrollRef} style={{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 10 }}>
76
+ {messages.length === 0 && (
77
+ <div style={{ opacity: 0.7, fontSize: 14 }}>{config?.greeting || "Hi! Ask me anything."}</div>
78
+ )}
79
+ {messages.map((m, i) => (
80
+ <div
81
+ key={i}
82
+ style={{
83
+ alignSelf: m.role === "user" ? "flex-end" : "flex-start",
84
+ maxWidth: "85%",
85
+ padding: "8px 12px",
86
+ borderRadius: 14,
87
+ fontSize: 14,
88
+ whiteSpace: m.role === "user" ? "pre-wrap" : "normal",
89
+ background: m.role === "user" ? accent : `${theme.colors.text}12`,
90
+ color: m.role === "user" ? onAccent : theme.colors.text,
91
+ }}
92
+ >
93
+ {m.role === "assistant" && m.text ? (
94
+ <Markdown>{m.text}</Markdown>
95
+ ) : (
96
+ m.text || (sending && i === messages.length - 1 ? "…" : "")
97
+ )}
98
+ </div>
99
+ ))}
100
+ {error && <div style={{ color: "#c0392b", fontSize: 13 }}>{error}</div>}
101
+ </div>
102
+
103
+ <div style={{ display: "flex", gap: 8, padding: 12, borderTop: `1px solid ${theme.colors.text}1a` }}>
104
+ <input
105
+ value={input}
106
+ onChange={(e) => setInput(e.target.value)}
107
+ onKeyDown={(e) => e.key === "Enter" && submit()}
108
+ placeholder="Type a message…"
109
+ disabled={sending}
110
+ style={{
111
+ flex: 1,
112
+ padding: "8px 12px",
113
+ borderRadius: theme.cornerRadius,
114
+ border: `1px solid ${theme.colors.text}33`,
115
+ background: theme.colors.background,
116
+ color: theme.colors.text,
117
+ outline: "none",
118
+ fontSize: 14,
119
+ }}
120
+ />
121
+ <button
122
+ onClick={submit}
123
+ disabled={sending || !input.trim()}
124
+ style={{ background: accent, color: onAccent, border: "none", borderRadius: theme.cornerRadius, padding: "0 16px", cursor: "pointer", fontWeight: 600 }}
125
+ >
126
+ Send
127
+ </button>
128
+ </div>
129
+ </div>
130
+ )}
131
+
132
+ <button
133
+ onClick={() => setOpen((o) => !o)}
134
+ aria-label="Open chat assistant"
135
+ style={{
136
+ width: 56,
137
+ height: 56,
138
+ borderRadius: "50%",
139
+ background: accent,
140
+ color: onAccent,
141
+ border: "none",
142
+ cursor: "pointer",
143
+ boxShadow: "0 6px 20px rgba(0,0,0,0.25)",
144
+ fontSize: 24,
145
+ float: position === "bottom-left" ? "left" : "right",
146
+ }}
147
+ >
148
+ {open ? "×" : "💬"}
149
+ </button>
150
+ </div>
151
+ );
152
+ }
@@ -0,0 +1,123 @@
1
+ import { SkipBack, SkipForward, Play, Pause, X } from "lucide-react";
2
+ import { useAudioPlayer } from "../../contexts/AudioPlayerContext";
3
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
+
5
+ function fmtTime(s: number): string {
6
+ if (!Number.isFinite(s) || s < 0) return "0:00";
7
+ const m = Math.floor(s / 60);
8
+ const sec = Math.floor(s % 60);
9
+ return `${m}:${sec.toString().padStart(2, "0")}`;
10
+ }
11
+
12
+ export interface AudioPlayerProps {
13
+ /** Hide when nothing is loaded (default true). */
14
+ hideWhenEmpty?: boolean;
15
+ /**
16
+ * Pin the player to the bottom of the viewport as a global playback bar
17
+ * (default true). Set false to render an inline card wherever it's placed.
18
+ */
19
+ fixed?: boolean;
20
+ }
21
+
22
+ /**
23
+ * Themed "now playing" bar bound to the Forge audio player. Renders as a fixed
24
+ * global playback bar by default (drop it once in your root layout); pass
25
+ * `fixed={false}` for an inline card. Includes a close button that stops
26
+ * playback and dismisses the bar.
27
+ */
28
+ export function AudioPlayer({ hideWhenEmpty = true, fixed = true }: AudioPlayerProps) {
29
+ const theme = useForgeTheme();
30
+ const a = useAudioPlayer();
31
+
32
+ if (!a.currentTrack && hideWhenEmpty) return null;
33
+
34
+ const ctrlBtn = {
35
+ background: "transparent",
36
+ border: "none",
37
+ cursor: "pointer",
38
+ color: theme.colors.text,
39
+ display: "inline-flex",
40
+ alignItems: "center",
41
+ padding: 4,
42
+ } as const;
43
+
44
+ const bar = (
45
+ <div
46
+ style={{
47
+ display: "flex",
48
+ alignItems: "center",
49
+ gap: 12,
50
+ padding: 10,
51
+ maxWidth: 1040,
52
+ margin: "0 auto",
53
+ background: theme.colors.background,
54
+ border: fixed ? "none" : `1px solid ${theme.colors.primary}20`,
55
+ borderRadius: fixed ? 0 : theme.cornerRadius,
56
+ color: theme.colors.text,
57
+ fontFamily: theme.fontFamily,
58
+ }}
59
+ >
60
+ {a.currentTrack?.coverImage && (
61
+ <img
62
+ src={a.currentTrack.coverImage}
63
+ alt=""
64
+ style={{ width: 44, height: 44, borderRadius: theme.cornerRadius, objectFit: "cover", flexShrink: 0 }}
65
+ />
66
+ )}
67
+ <button onClick={a.playPrevious} style={ctrlBtn} aria-label="Previous">
68
+ <SkipBack size={18} />
69
+ </button>
70
+ <button onClick={() => (a.isPlaying ? a.pause() : a.play())} style={ctrlBtn} aria-label={a.isPlaying ? "Pause" : "Play"}>
71
+ {a.isPlaying ? <Pause size={20} /> : <Play size={20} />}
72
+ </button>
73
+ <button onClick={a.playNext} style={ctrlBtn} aria-label="Next">
74
+ <SkipForward size={18} />
75
+ </button>
76
+
77
+ <div style={{ flex: 1, minWidth: 0 }}>
78
+ <div style={{ fontWeight: 600, fontSize: 14, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
79
+ {a.currentTrack?.title ?? "Nothing playing"}
80
+ {a.currentTrack?.artist ? ` — ${a.currentTrack.artist}` : ""}
81
+ </div>
82
+ <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, opacity: 0.8 }}>
83
+ <span>{fmtTime(a.currentTime)}</span>
84
+ <input
85
+ type="range"
86
+ min={0}
87
+ max={a.duration || 0}
88
+ value={a.currentTime}
89
+ onChange={(e) => a.seek(Number(e.target.value))}
90
+ style={{ flex: 1, accentColor: theme.colors.primary }}
91
+ />
92
+ <span>{fmtTime(a.duration)}</span>
93
+ </div>
94
+ </div>
95
+
96
+ <button onClick={a.stop} style={{ ...ctrlBtn, opacity: 0.7 }} aria-label="Close player">
97
+ <X size={18} />
98
+ </button>
99
+ </div>
100
+ );
101
+
102
+ if (!fixed) return bar;
103
+
104
+ return (
105
+ <div
106
+ style={{
107
+ position: "fixed",
108
+ left: 0,
109
+ right: 0,
110
+ bottom: 0,
111
+ zIndex: 50,
112
+ background: `color-mix(in srgb, ${theme.colors.background} 92%, transparent)`,
113
+ backdropFilter: "blur(8px)",
114
+ WebkitBackdropFilter: "blur(8px)",
115
+ borderTop: `1px solid ${theme.colors.primary}24`,
116
+ boxShadow: "0 -8px 30px -20px rgba(0,0,0,0.5)",
117
+ paddingBottom: "env(safe-area-inset-bottom)",
118
+ }}
119
+ >
120
+ {bar}
121
+ </div>
122
+ );
123
+ }
@@ -0,0 +1,79 @@
1
+ import { useBlogCategory, useBlogCategoryPosts } from "../../data/queries/useBlog";
2
+ import type { BlogPost, BlogCategory as BlogCategoryModel } from "../../types/models";
3
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
+ import { Loading } from "./Loading";
5
+
6
+ export interface BlogCategoryProps {
7
+ /** Category slug. */
8
+ slug: string;
9
+ /** Build the href for a blog post detail page from its slug. */
10
+ blogPostHref?: (slug: string) => string;
11
+ /** Server-fetched category to seed the query for SSR (no client loading flash). */
12
+ initialCategory?: BlogCategoryModel | null;
13
+ }
14
+
15
+ function excerpt(html: string, len: number) {
16
+ return html.replace(/<[^>]+>/g, "").slice(0, len);
17
+ }
18
+
19
+ /** Blog category page — grid of posts in a category. Built on `useBlogCategory` + `useBlogCategoryPosts`. */
20
+ export function BlogCategory({ slug, blogPostHref = (s) => `/i/blog/${s}`, initialCategory }: BlogCategoryProps) {
21
+ const theme = useForgeTheme();
22
+ const { data: category, isLoading: catLoading } = useBlogCategory(slug, { initialData: initialCategory });
23
+ const { data: posts, isLoading: postsLoading } = useBlogCategoryPosts(slug, { page: 1, limit: 12 });
24
+
25
+ if (catLoading || postsLoading) return <Loading fullPage />;
26
+ if (!category) return <p>Category not found.</p>;
27
+
28
+ const list = posts?.data ?? [];
29
+
30
+ return (
31
+ <div style={{ maxWidth: 960, margin: "0 auto", color: theme.colors.text }}>
32
+ <header style={{ marginBottom: 32 }}>
33
+ <h1 style={{ fontSize: 30, fontWeight: 800 }}>{category.title}</h1>
34
+ {category.description && <p style={{ marginTop: 8, opacity: 0.7 }}>{category.description}</p>}
35
+ </header>
36
+
37
+ {list.length ? (
38
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))", gap: 24 }}>
39
+ {list.map((post: BlogPost) => {
40
+ const cover = post.media?.find((m) => m.type.startsWith("image"))?.url ?? post.media?.[0]?.url;
41
+ return (
42
+ <a
43
+ key={post.id}
44
+ href={blogPostHref(post.slug)}
45
+ style={{
46
+ color: theme.colors.text,
47
+ textDecoration: "none",
48
+ border: `1px solid ${theme.colors.text}1a`,
49
+ borderRadius: theme.cornerRadius,
50
+ overflow: "hidden",
51
+ display: "block",
52
+ }}
53
+ >
54
+ {cover ? (
55
+ <img src={cover} alt={post.title} style={{ width: "100%", aspectRatio: "1/1", objectFit: "cover" }} />
56
+ ) : (
57
+ <div style={{ width: "100%", aspectRatio: "1/1", background: `${theme.colors.text}0d` }} />
58
+ )}
59
+ <div style={{ padding: 16 }}>
60
+ <h3 style={{ fontWeight: 600 }}>{post.title}</h3>
61
+ {post.content && (
62
+ <p style={{ fontSize: 14, opacity: 0.7, marginTop: 6 }}>{excerpt(post.content, 140)}…</p>
63
+ )}
64
+ {post.publishedAt && (
65
+ <p style={{ fontSize: 12, opacity: 0.5, marginTop: 6 }}>
66
+ {new Date(post.publishedAt).toLocaleDateString()}
67
+ </p>
68
+ )}
69
+ </div>
70
+ </a>
71
+ );
72
+ })}
73
+ </div>
74
+ ) : (
75
+ <p style={{ opacity: 0.7 }}>No posts yet.</p>
76
+ )}
77
+ </div>
78
+ );
79
+ }
@@ -0,0 +1,236 @@
1
+ import { useMemo, useState, type CSSProperties } from "react";
2
+ import { useForge } from "../../provider/ForgeProvider";
3
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
+ import { readableTextOn } from "../theme/contrast";
5
+ import {
6
+ useBlogComments,
7
+ useBlogCommentSettings,
8
+ useSubmitBlogComment,
9
+ type BlogComment,
10
+ } from "../../data/queries/useBlogComments";
11
+ import { BotProtection, EMPTY_BOT_FIELDS, type BotFields } from "./BotProtection";
12
+
13
+ export interface BlogCommentsProps {
14
+ blogPostId: string;
15
+ /** Comments per page (matches the paginated public API). */
16
+ pageSize?: number;
17
+ title?: string;
18
+ /** Where "Sign in to comment" links when the profile requires sign-in. */
19
+ signInHref?: string;
20
+ className?: string;
21
+ style?: CSSProperties;
22
+ }
23
+
24
+ function formatDate(iso: string): string {
25
+ const d = new Date(iso);
26
+ return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
27
+ }
28
+
29
+ /**
30
+ * Paginated blog comments + submit form. Shared across every Forge surface
31
+ * (apps/client, site-starter, AI-code-built sites). Renders nothing when the
32
+ * profile has comments disabled.
33
+ */
34
+ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", signInHref, className, style }: BlogCommentsProps) {
35
+ const theme = useForgeTheme();
36
+ const { token } = useForge();
37
+ const isSignedIn = !!token;
38
+
39
+ const { data: settings } = useBlogCommentSettings();
40
+ const commentsQuery = useBlogComments(blogPostId, pageSize);
41
+ const submit = useSubmitBlogComment();
42
+
43
+ const [content, setContent] = useState("");
44
+ const [name, setName] = useState("");
45
+ const [email, setEmail] = useState("");
46
+ const [error, setError] = useState<string | null>(null);
47
+ const [pendingNotice, setPendingNotice] = useState(false);
48
+ const [botFields, setBotFields] = useState<BotFields>(EMPTY_BOT_FIELDS);
49
+ const [botResetKey, setBotResetKey] = useState(0);
50
+
51
+ const comments = useMemo<BlogComment[]>(
52
+ () => (commentsQuery.data?.pages ?? []).flatMap((p) => p.data),
53
+ [commentsQuery.data],
54
+ );
55
+ const total = commentsQuery.data?.pages?.[0]?.total ?? 0;
56
+
57
+ const muted = theme.colors.muted ?? `${theme.colors.text}99`;
58
+ const border = theme.colors.border ?? `${theme.colors.primary}33`;
59
+ const inputStyle: CSSProperties = {
60
+ width: "100%",
61
+ padding: 10,
62
+ border: `1px solid ${border}`,
63
+ borderRadius: theme.cornerRadius,
64
+ background: theme.colors.background,
65
+ color: theme.colors.text,
66
+ fontFamily: theme.fontFamily,
67
+ };
68
+
69
+ if (settings && !settings.enabled) return null;
70
+
71
+ const mustSignIn = settings?.requireSignIn && !isSignedIn;
72
+
73
+ const onSubmit = async () => {
74
+ setError(null);
75
+ const trimmed = content.trim();
76
+ if (!trimmed) {
77
+ setError("Please write a comment.");
78
+ return;
79
+ }
80
+ if (!isSignedIn && (!name.trim() || !email.trim())) {
81
+ setError("Name and email are required.");
82
+ return;
83
+ }
84
+ try {
85
+ const result = await submit.mutateAsync({
86
+ blogPostId,
87
+ content: trimmed,
88
+ ...botFields,
89
+ ...(isSignedIn ? {} : { guestName: name.trim(), guestEmail: email.trim() }),
90
+ });
91
+ setContent("");
92
+ setName("");
93
+ setEmail("");
94
+ setPendingNotice(result.pending);
95
+ // Refresh bot protection — the proof-of-work token is single-use.
96
+ setBotResetKey((k) => k + 1);
97
+ } catch (e) {
98
+ const message =
99
+ (e as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "Something went wrong. Please try again.";
100
+ setError(message);
101
+ }
102
+ };
103
+
104
+ return (
105
+ <section
106
+ className={className}
107
+ // textAlign:left so the section never inherits a centered alignment from
108
+ // the artist's page/theme (comments read left-aligned like an article).
109
+ style={{ color: theme.colors.text, fontFamily: theme.fontFamily, textAlign: "left", ...style }}
110
+ >
111
+ <h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 16 }}>
112
+ {title}
113
+ {total > 0 ? ` (${total})` : ""}
114
+ </h2>
115
+
116
+ {/* Form */}
117
+ {mustSignIn ? (
118
+ <p style={{ color: muted, marginBottom: 24 }}>
119
+ {signInHref ? (
120
+ <a href={signInHref} style={{ color: theme.colors.primary }}>
121
+ Sign in
122
+ </a>
123
+ ) : (
124
+ "Sign in"
125
+ )}{" "}
126
+ to leave a comment.
127
+ </p>
128
+ ) : pendingNotice ? (
129
+ <div
130
+ style={{ padding: 12, borderRadius: theme.cornerRadius, background: `${theme.colors.primary}14`, marginBottom: 24 }}
131
+ >
132
+ Thanks! Your comment is awaiting moderation.
133
+ <button
134
+ onClick={() => setPendingNotice(false)}
135
+ style={{ marginLeft: 8, background: "none", border: "none", color: theme.colors.primary, cursor: "pointer" }}
136
+ >
137
+ Write another
138
+ </button>
139
+ </div>
140
+ ) : (
141
+ <div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 28 }}>
142
+ {!isSignedIn && (
143
+ <div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
144
+ <input placeholder="Your name" value={name} onChange={(e) => setName(e.target.value)} style={{ ...inputStyle, flex: 1, minWidth: 160 }} />
145
+ <input
146
+ type="email"
147
+ placeholder="Email (not published)"
148
+ value={email}
149
+ onChange={(e) => setEmail(e.target.value)}
150
+ style={{ ...inputStyle, flex: 1, minWidth: 160 }}
151
+ />
152
+ </div>
153
+ )}
154
+ <textarea
155
+ placeholder="Add a comment…"
156
+ value={content}
157
+ onChange={(e) => setContent(e.target.value)}
158
+ rows={3}
159
+ style={{ ...inputStyle, resize: "vertical" }}
160
+ />
161
+ <BotProtection key={botResetKey} onChange={setBotFields} />
162
+ {error && <p style={{ color: "#ef4444", fontSize: 14 }}>{error}</p>}
163
+ <div>
164
+ <button
165
+ onClick={onSubmit}
166
+ disabled={submit.isPending}
167
+ style={{
168
+ background: theme.colors.primary,
169
+ color: theme.colors.textPrimary ?? readableTextOn(theme.colors.primary),
170
+ border: "none",
171
+ borderRadius: theme.cornerRadius,
172
+ padding: "10px 20px",
173
+ fontWeight: 600,
174
+ cursor: submit.isPending ? "default" : "pointer",
175
+ opacity: submit.isPending ? 0.7 : 1,
176
+ }}
177
+ >
178
+ {submit.isPending ? "Posting…" : "Post comment"}
179
+ </button>
180
+ </div>
181
+ </div>
182
+ )}
183
+
184
+ {/* List */}
185
+ {commentsQuery.isLoading ? (
186
+ <p style={{ color: muted }}>Loading comments…</p>
187
+ ) : comments.length === 0 ? (
188
+ <p style={{ color: muted }}>No comments yet. Be the first.</p>
189
+ ) : (
190
+ <div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
191
+ {comments.map((c) => (
192
+ <div key={c.id}>
193
+ <div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
194
+ <strong>{c.authorName || "Anonymous"}</strong>
195
+ <span style={{ color: muted, fontSize: 13 }}>{formatDate(c.createdAt)}</span>
196
+ </div>
197
+ <p style={{ margin: "4px 0", whiteSpace: "pre-wrap" }}>{c.content}</p>
198
+ {c.replies?.length > 0 && (
199
+ <div style={{ marginTop: 10, paddingLeft: 16, borderLeft: `2px solid ${border}`, display: "flex", flexDirection: "column", gap: 12 }}>
200
+ {c.replies.map((r) => (
201
+ <div key={r.id}>
202
+ <div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
203
+ <strong style={{ color: theme.colors.primary }}>{r.authorName || "Author"}</strong>
204
+ <span style={{ color: muted, fontSize: 13 }}>{formatDate(r.createdAt)}</span>
205
+ </div>
206
+ <p style={{ margin: "4px 0", whiteSpace: "pre-wrap" }}>{r.content}</p>
207
+ </div>
208
+ ))}
209
+ </div>
210
+ )}
211
+ </div>
212
+ ))}
213
+ </div>
214
+ )}
215
+
216
+ {commentsQuery.hasNextPage && (
217
+ <div style={{ marginTop: 20 }}>
218
+ <button
219
+ onClick={() => commentsQuery.fetchNextPage()}
220
+ disabled={commentsQuery.isFetchingNextPage}
221
+ style={{
222
+ background: "transparent",
223
+ color: theme.colors.primary,
224
+ border: `1px solid ${border}`,
225
+ borderRadius: theme.cornerRadius,
226
+ padding: "8px 16px",
227
+ cursor: "pointer",
228
+ }}
229
+ >
230
+ {commentsQuery.isFetchingNextPage ? "Loading…" : "Load more comments"}
231
+ </button>
232
+ </div>
233
+ )}
234
+ </section>
235
+ );
236
+ }