@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,227 @@
1
+ import type { CSSProperties } from "react";
2
+ import { ArrowLeft, Lock } from "lucide-react";
3
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
4
+ import {
5
+ usePostCollectionDetail,
6
+ type UsePostCollectionDetailOptions,
7
+ } from "../../headless/posts/usePostCollectionDetail";
8
+ import { Loading } from "../Loading";
9
+ import { MemberPostCard } from "./MemberPostCard";
10
+
11
+ export interface PostCollectionDetailProps extends UsePostCollectionDetailOptions {
12
+ /** A collection uuid OR its per-profile slug — the public detail endpoint resolves either. */
13
+ collectionId: string;
14
+ /** Where the back button returns to (default `/i/members/collections`). */
15
+ backHref?: string;
16
+ /** Shown as the audio track artist for audio posts inside the collection. */
17
+ artistName?: string;
18
+ /** SPA navigation adapter (back button + upsell CTA). Defaults to a full-page visit. */
19
+ navigate?: (href: string) => void;
20
+ /** Called when a logged-out visitor tries to like/save/comment (default: navigate to `loginPath`). */
21
+ onRequireAuth?: () => void;
22
+ /** Login path for the default require-auth redirect (site-starter passes `/i/login`). */
23
+ loginPath?: string;
24
+ className?: string;
25
+ style?: CSSProperties;
26
+ }
27
+
28
+ /**
29
+ * One collection's drill-in view — parity with the craft members area's
30
+ * `CollectionsContent` detail: back link, cover + title + rich-text
31
+ * description header, then the collection's posts as full member post cards
32
+ * (engagement, audio queue scoped to the collection, per-post gating). A
33
+ * locked collection renders the upgrade CTA instead of posts — access is
34
+ * server-computed, never faked client-side. Built on the headless
35
+ * `usePostCollectionDetail`.
36
+ */
37
+ export function PostCollectionDetail({
38
+ collectionId,
39
+ backHref = "/i/members/collections",
40
+ artistName,
41
+ navigate,
42
+ onRequireAuth,
43
+ loginPath = "/login",
44
+ className,
45
+ style,
46
+ ...detailOptions
47
+ }: PostCollectionDetailProps) {
48
+ const t = useThemeTokens();
49
+ const detail = usePostCollectionDetail(collectionId, detailOptions);
50
+ const go = navigate ?? ((href: string) => window.location.assign(href));
51
+ const requireAuth =
52
+ onRequireAuth ??
53
+ (() => {
54
+ const redirect = typeof window !== "undefined" ? `${window.location.pathname}${window.location.search}` : "";
55
+ go(redirect ? `${loginPath}?redirect=${encodeURIComponent(redirect)}` : loginPath);
56
+ });
57
+
58
+ const backButton = (
59
+ <button
60
+ type="button"
61
+ onClick={() => go(backHref)}
62
+ style={{
63
+ display: "inline-flex",
64
+ alignItems: "center",
65
+ gap: 8,
66
+ alignSelf: "flex-start",
67
+ padding: "8px 14px",
68
+ borderRadius: t.cornerRadius,
69
+ border: `1px solid ${t.border}`,
70
+ background: t.surface,
71
+ color: t.text,
72
+ fontSize: 13,
73
+ fontWeight: 600,
74
+ cursor: "pointer",
75
+ }}
76
+ >
77
+ <ArrowLeft size={14} style={{ color: t.primary }} /> All collections
78
+ </button>
79
+ );
80
+
81
+ if (detail.isLoading) {
82
+ return (
83
+ <div className={className} style={{ display: "flex", flexDirection: "column", gap: 16, ...style }}>
84
+ {backButton}
85
+ <Loading />
86
+ </div>
87
+ );
88
+ }
89
+
90
+ if (detail.isError || !detail.collection) {
91
+ return (
92
+ <div
93
+ className={className}
94
+ style={{ display: "flex", flexDirection: "column", gap: 16, fontFamily: t.fontFamily, ...style }}
95
+ >
96
+ {backButton}
97
+ <p style={{ textAlign: "center", padding: "40px 20px", color: t.muted, fontSize: 15 }}>Collection not found</p>
98
+ </div>
99
+ );
100
+ }
101
+
102
+ const collection = detail.collection;
103
+
104
+ return (
105
+ <div
106
+ className={className}
107
+ style={{ display: "flex", flexDirection: "column", gap: 16, color: t.text, fontFamily: t.fontFamily, ...style }}
108
+ >
109
+ {backButton}
110
+
111
+ {/* Collection header */}
112
+ <div
113
+ style={{
114
+ padding: 24,
115
+ borderRadius: t.cornerRadius,
116
+ background: t.surface,
117
+ border: `1px solid ${t.border}`,
118
+ }}
119
+ >
120
+ {detail.cover && (
121
+ <img
122
+ src={detail.cover.url}
123
+ alt={collection.title}
124
+ style={{
125
+ width: "100%",
126
+ aspectRatio: "1 / 1",
127
+ objectFit: "cover",
128
+ display: "block",
129
+ borderRadius: t.cornerRadius,
130
+ marginBottom: 16,
131
+ }}
132
+ />
133
+ )}
134
+ <h2 style={{ margin: 0, fontSize: 26, fontWeight: 700 }}>{collection.title}</h2>
135
+ {collection.description && (
136
+ <div
137
+ style={{ marginTop: 8, fontSize: 15, lineHeight: 1.6, color: t.muted }}
138
+ dangerouslySetInnerHTML={{ __html: collection.description }}
139
+ />
140
+ )}
141
+ </div>
142
+
143
+ {/* Locked collection → upgrade CTA (server-gated; posts are not shipped). */}
144
+ {!detail.hasAccess ? (
145
+ <div
146
+ style={{
147
+ display: "flex",
148
+ flexDirection: "column",
149
+ alignItems: "center",
150
+ gap: 12,
151
+ padding: "40px 16px",
152
+ borderRadius: t.cornerRadius,
153
+ background: `${t.primary}0d`,
154
+ textAlign: "center",
155
+ }}
156
+ >
157
+ <span
158
+ style={{
159
+ width: 56,
160
+ height: 56,
161
+ borderRadius: "50%",
162
+ background: `${t.primary}22`,
163
+ color: t.primary,
164
+ display: "flex",
165
+ alignItems: "center",
166
+ justifyContent: "center",
167
+ }}
168
+ >
169
+ <Lock size={24} />
170
+ </span>
171
+ <div>
172
+ <div style={{ fontSize: 16, fontWeight: 700 }}>Content locked</div>
173
+ <div style={{ fontSize: 13, color: t.muted, marginTop: 2 }}>
174
+ {detail.upsell?.tier?.name
175
+ ? `Subscribe to ${detail.upsell.tier.name} to access this content`
176
+ : "Subscribe to access this content"}
177
+ </div>
178
+ </div>
179
+ {detail.upsell && (
180
+ <button
181
+ type="button"
182
+ onClick={() => go(detail.upsell!.href)}
183
+ style={{
184
+ padding: "10px 18px",
185
+ borderRadius: t.cornerRadius,
186
+ border: "none",
187
+ background: t.primary,
188
+ color: t.textPrimary,
189
+ fontWeight: 600,
190
+ cursor: "pointer",
191
+ }}
192
+ >
193
+ Subscribe
194
+ </button>
195
+ )}
196
+ </div>
197
+ ) : (
198
+ <>
199
+ <div style={{ fontSize: 16, fontWeight: 700 }}>
200
+ Collection items: {detail.posts.length}
201
+ </div>
202
+ {detail.posts.length === 0 ? (
203
+ <p style={{ textAlign: "center", padding: "40px 20px", color: t.muted, fontSize: 15 }}>
204
+ No posts in this collection
205
+ </p>
206
+ ) : (
207
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
208
+ {detail.posts.map((post) => (
209
+ <MemberPostCard
210
+ key={post.id}
211
+ post={post}
212
+ hideHeader
213
+ artistName={artistName}
214
+ otherAudioPosts={detail.audioPosts.filter((p) => p.id !== post.id)}
215
+ navigate={go}
216
+ onRequireAuth={requireAuth}
217
+ checkoutPath={detailOptions.checkoutPath}
218
+ signupPath={detailOptions.signupPath}
219
+ />
220
+ ))}
221
+ </div>
222
+ )}
223
+ </>
224
+ )}
225
+ </div>
226
+ );
227
+ }
@@ -0,0 +1,287 @@
1
+ import { useState, type CSSProperties } from "react";
2
+ import { ChevronLeft, ChevronRight, FileText, Lock, Search, X } from "lucide-react";
3
+ import type { IPublicPostCollection } from "../../../types/models";
4
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
5
+ import {
6
+ usePostCollectionsList,
7
+ type UsePostCollectionsListOptions,
8
+ } from "../../headless/posts/usePostCollectionsList";
9
+ import type { PostUpsell } from "../../headless/posts/upsell";
10
+ import { Loading } from "../Loading";
11
+
12
+ export interface PostCollectionsGridProps extends UsePostCollectionsListOptions {
13
+ /**
14
+ * Builds the drill-in href for an accessible collection. Receives the
15
+ * collection's slug when it has one, else its uuid — the public detail
16
+ * endpoint resolves either. Default `` (idOrSlug) => `/i/members/collections/${idOrSlug}` ``.
17
+ */
18
+ getCollectionHref?: (idOrSlug: string) => string;
19
+ /** SPA navigation adapter (drill-in + locked-collection upsell CTA). Defaults to a full-page visit. */
20
+ navigate?: (href: string) => void;
21
+ className?: string;
22
+ style?: CSSProperties;
23
+ }
24
+
25
+ const stripHtml = (html: string, len = 100) => {
26
+ const text = html
27
+ .replace(/<[^>]+>/g, " ")
28
+ .replace(/\s+/g, " ")
29
+ .trim();
30
+ return text.length > len ? text.slice(0, len) + "…" : text;
31
+ };
32
+
33
+ /**
34
+ * The member collections grid — parity with the craft members area's
35
+ * `CollectionsContent` list: cover, title, description, post count per
36
+ * collection; locked collections render dimmed with a Subscribe CTA that
37
+ * deep-links membership checkout (`hasAccess` is server-computed — a locked
38
+ * collection never opens); accessible collections drill into the detail view.
39
+ * Debounced search + numbered paging. Built on the headless
40
+ * `usePostCollectionsList`.
41
+ */
42
+ export function PostCollectionsGrid({
43
+ getCollectionHref = (idOrSlug) => `/i/members/collections/${idOrSlug}`,
44
+ navigate,
45
+ className,
46
+ style,
47
+ ...listOptions
48
+ }: PostCollectionsGridProps) {
49
+ const t = useThemeTokens();
50
+ const list = usePostCollectionsList(listOptions);
51
+ const go = navigate ?? ((href: string) => window.location.assign(href));
52
+ const [searchFocused, setSearchFocused] = useState(false);
53
+
54
+ return (
55
+ <div
56
+ className={className}
57
+ style={{ display: "flex", flexDirection: "column", gap: 16, color: t.text, fontFamily: t.fontFamily, ...style }}
58
+ >
59
+ {/* Search */}
60
+ <div
61
+ style={{
62
+ display: "flex",
63
+ alignItems: "center",
64
+ gap: 8,
65
+ padding: "10px 14px",
66
+ borderRadius: t.cornerRadius,
67
+ border: `1px solid ${searchFocused ? t.primary : t.border}`,
68
+ background: t.surface,
69
+ }}
70
+ >
71
+ <Search size={16} style={{ color: t.muted, flexShrink: 0 }} />
72
+ <input
73
+ value={list.search}
74
+ onChange={(e) => list.setSearch(e.target.value)}
75
+ onFocus={() => setSearchFocused(true)}
76
+ onBlur={() => setSearchFocused(false)}
77
+ placeholder="Search collections…"
78
+ style={{ flex: 1, border: "none", outline: "none", background: "transparent", color: t.text, fontSize: 14 }}
79
+ />
80
+ {list.search && (
81
+ <button
82
+ type="button"
83
+ aria-label="Clear search"
84
+ onClick={() => list.clearFilters()}
85
+ style={{ border: "none", background: "transparent", color: t.muted, cursor: "pointer", display: "flex" }}
86
+ >
87
+ <X size={14} />
88
+ </button>
89
+ )}
90
+ </div>
91
+
92
+ {/* Grid */}
93
+ {list.isLoading ? (
94
+ <Loading />
95
+ ) : list.collections.length === 0 ? (
96
+ <p style={{ textAlign: "center", padding: "40px 20px", color: t.muted, fontSize: 15 }}>
97
+ {list.hasActiveFilters ? "No collections found matching your filters" : "No collections yet"}
98
+ </p>
99
+ ) : (
100
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
101
+ {list.collections.map((collection) => (
102
+ <CollectionCard
103
+ key={collection.id}
104
+ collection={collection}
105
+ upsell={list.getUpsell(collection)}
106
+ onOpen={() => go(getCollectionHref(collection.slug ?? collection.id))}
107
+ go={go}
108
+ />
109
+ ))}
110
+ </div>
111
+ )}
112
+
113
+ {/* Pagination */}
114
+ {list.totalPages > 1 && (
115
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12 }}>
116
+ <button
117
+ type="button"
118
+ onClick={list.prevPage}
119
+ disabled={!list.hasPrevPage}
120
+ aria-label="Previous page"
121
+ style={{
122
+ display: "flex",
123
+ alignItems: "center",
124
+ gap: 4,
125
+ padding: "8px 14px",
126
+ borderRadius: t.cornerRadius,
127
+ border: `1px solid ${t.border}`,
128
+ background: "transparent",
129
+ color: t.text,
130
+ fontSize: 13,
131
+ cursor: list.hasPrevPage ? "pointer" : "not-allowed",
132
+ opacity: list.hasPrevPage ? 1 : 0.4,
133
+ }}
134
+ >
135
+ <ChevronLeft size={14} /> Prev
136
+ </button>
137
+ <span style={{ fontSize: 13, color: t.muted }}>
138
+ Page {list.page} of {list.totalPages}
139
+ </span>
140
+ <button
141
+ type="button"
142
+ onClick={list.nextPage}
143
+ disabled={!list.hasNextPage}
144
+ aria-label="Next page"
145
+ style={{
146
+ display: "flex",
147
+ alignItems: "center",
148
+ gap: 4,
149
+ padding: "8px 14px",
150
+ borderRadius: t.cornerRadius,
151
+ border: `1px solid ${t.border}`,
152
+ background: "transparent",
153
+ color: t.text,
154
+ fontSize: 13,
155
+ cursor: list.hasNextPage ? "pointer" : "not-allowed",
156
+ opacity: list.hasNextPage ? 1 : 0.4,
157
+ }}
158
+ >
159
+ Next <ChevronRight size={14} />
160
+ </button>
161
+ </div>
162
+ )}
163
+ </div>
164
+ );
165
+ }
166
+
167
+ function CollectionCard({
168
+ collection,
169
+ upsell,
170
+ onOpen,
171
+ go,
172
+ }: {
173
+ collection: IPublicPostCollection;
174
+ upsell: PostUpsell;
175
+ onOpen: () => void;
176
+ go: (href: string) => void;
177
+ }) {
178
+ const t = useThemeTokens();
179
+ const locked = !collection.hasAccess;
180
+ const cover = collection.media?.[0];
181
+ const description = collection.description ? stripHtml(collection.description) : "";
182
+ const tierName = upsell.tier?.name;
183
+
184
+ return (
185
+ <div
186
+ role={locked ? undefined : "button"}
187
+ tabIndex={locked ? undefined : 0}
188
+ onClick={locked ? undefined : onOpen}
189
+ onKeyDown={
190
+ locked
191
+ ? undefined
192
+ : (e) => {
193
+ if (e.key === "Enter" || e.key === " ") {
194
+ e.preventDefault();
195
+ onOpen();
196
+ }
197
+ }
198
+ }
199
+ aria-label={locked ? `${collection.title} (locked)` : `Open collection ${collection.title}`}
200
+ style={{
201
+ opacity: locked ? 0.6 : 1,
202
+ borderRadius: t.cornerRadius,
203
+ background: t.surface,
204
+ border: `1px solid ${t.border}`,
205
+ overflow: "hidden",
206
+ cursor: locked ? "default" : "pointer",
207
+ color: t.text,
208
+ fontFamily: t.fontFamily,
209
+ }}
210
+ >
211
+ {cover && (
212
+ <img
213
+ src={cover.url}
214
+ alt={collection.title}
215
+ style={{ width: "100%", aspectRatio: "1 / 1", objectFit: "cover", display: "block" }}
216
+ />
217
+ )}
218
+ <div style={{ padding: 20 }}>
219
+ <h3 style={{ margin: 0, fontSize: 18, fontWeight: 700 }}>{collection.title}</h3>
220
+ {description && (
221
+ <p style={{ margin: "8px 0 0", fontSize: 13, lineHeight: 1.5, color: t.muted }}>{description}</p>
222
+ )}
223
+ <div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 12, fontSize: 13, color: t.muted }}>
224
+ <FileText size={14} />
225
+ <span>
226
+ {collection.posts?.length || 0} {(collection.posts?.length || 0) === 1 ? "post" : "posts"}
227
+ </span>
228
+ </div>
229
+
230
+ {locked && (
231
+ <div
232
+ style={{
233
+ display: "flex",
234
+ flexDirection: "column",
235
+ alignItems: "center",
236
+ gap: 8,
237
+ marginTop: 16,
238
+ padding: "16px 12px",
239
+ borderRadius: t.cornerRadius,
240
+ background: `${t.primary}0d`,
241
+ textAlign: "center",
242
+ }}
243
+ >
244
+ <span
245
+ style={{
246
+ width: 40,
247
+ height: 40,
248
+ borderRadius: "50%",
249
+ background: `${t.primary}22`,
250
+ color: t.primary,
251
+ display: "flex",
252
+ alignItems: "center",
253
+ justifyContent: "center",
254
+ }}
255
+ >
256
+ <Lock size={18} />
257
+ </span>
258
+ <div style={{ fontSize: 15, fontWeight: 700 }}>Content locked</div>
259
+ <div style={{ fontSize: 13, color: t.muted }}>
260
+ {tierName ? `Subscribe to ${tierName} to access this content` : "Subscribe to access this content"}
261
+ </div>
262
+ <button
263
+ type="button"
264
+ onClick={(e) => {
265
+ e.stopPropagation();
266
+ go(upsell.href);
267
+ }}
268
+ style={{
269
+ marginTop: 4,
270
+ padding: "8px 16px",
271
+ borderRadius: t.cornerRadius,
272
+ border: "none",
273
+ background: t.primary,
274
+ color: t.textPrimary,
275
+ fontWeight: 600,
276
+ fontSize: 13,
277
+ cursor: "pointer",
278
+ }}
279
+ >
280
+ Subscribe
281
+ </button>
282
+ </div>
283
+ )}
284
+ </div>
285
+ </div>
286
+ );
287
+ }
@@ -0,0 +1,7 @@
1
+ // Unified member home styled blocks. NOTE: lane-local aggregator — the
2
+ // integrator re-exports it from `ui/index.ts` (`export * from "./styled/members"`).
3
+ export { MemberHomeLayout, type MemberHomeLayoutProps } from "./MemberHomeLayout";
4
+ export { MemberPostsFeed, type MemberPostsFeedProps } from "./MemberPostsFeed";
5
+ export { MemberPostCard, type MemberPostCardProps } from "./MemberPostCard";
6
+ export { PostCollectionsGrid, type PostCollectionsGridProps } from "./PostCollectionsGrid";
7
+ export { PostCollectionDetail, type PostCollectionDetailProps } from "./PostCollectionDetail";
@@ -0,0 +1,150 @@
1
+ /*
2
+ * Baseline styles for rich-text content rendered by Forge's styled blocks
3
+ * (BlogPost, PodcastEpisode). Ported from
4
+ * packages/frontend-shared/src/rich-text.css — the SAME rules are applied to
5
+ * the admin editor's content area, so authoring matches the result (WYSIWYG).
6
+ * Keep the two files in sync when the editor baseline changes.
7
+ *
8
+ * Bundled with Forge: the styled blocks that render `.rich-text` content import
9
+ * this file directly, so consumers get correct rendering with no host-loaded
10
+ * stylesheet. Structure + spacing only — color and font are inherited from each
11
+ * surface, so it never fights per-tenant theming.
12
+ */
13
+
14
+ .rich-text {
15
+ line-height: 1.6;
16
+ }
17
+ .rich-text > :first-child {
18
+ margin-top: 0;
19
+ }
20
+
21
+ .rich-text p {
22
+ margin: 0 0 1rem;
23
+ }
24
+
25
+ .rich-text h1 {
26
+ font-size: 1.875rem;
27
+ font-weight: 800;
28
+ line-height: 1.2;
29
+ margin: 1.75rem 0 0.75rem;
30
+ }
31
+ .rich-text h2 {
32
+ font-size: 1.5rem;
33
+ font-weight: 700;
34
+ line-height: 1.25;
35
+ margin: 1.5rem 0 0.75rem;
36
+ }
37
+ .rich-text h3 {
38
+ font-size: 1.25rem;
39
+ font-weight: 700;
40
+ line-height: 1.3;
41
+ margin: 1.25rem 0 0.5rem;
42
+ }
43
+ .rich-text h4 {
44
+ font-size: 1.125rem;
45
+ font-weight: 700;
46
+ margin: 1rem 0 0.5rem;
47
+ }
48
+ .rich-text h5 {
49
+ font-size: 1rem;
50
+ font-weight: 700;
51
+ margin: 1rem 0 0.5rem;
52
+ }
53
+
54
+ /* list-style-position defaults to `outside`, so wrapped lines hang-indent and
55
+ align under the text; full width (no max-width). */
56
+ .rich-text ul {
57
+ list-style: disc;
58
+ padding-left: 1.5rem;
59
+ margin: 0 0 1rem;
60
+ }
61
+ .rich-text ol {
62
+ list-style: decimal;
63
+ padding-left: 1.5rem;
64
+ margin: 0 0 1rem;
65
+ }
66
+ .rich-text li {
67
+ margin: 0.25rem 0;
68
+ }
69
+ .rich-text li > ul,
70
+ .rich-text li > ol {
71
+ margin: 0.25rem 0;
72
+ }
73
+
74
+ .rich-text blockquote {
75
+ border-left: 3px solid currentColor;
76
+ padding-left: 1rem;
77
+ margin: 0 0 1rem;
78
+ opacity: 0.85;
79
+ font-style: italic;
80
+ }
81
+
82
+ .rich-text a {
83
+ text-decoration: underline;
84
+ }
85
+ .rich-text strong,
86
+ .rich-text b {
87
+ font-weight: 700;
88
+ }
89
+ .rich-text em,
90
+ .rich-text i {
91
+ font-style: italic;
92
+ }
93
+
94
+ .rich-text hr {
95
+ margin: 1.5rem 0;
96
+ border: 0;
97
+ border-top: 1px solid currentColor;
98
+ opacity: 0.2;
99
+ }
100
+
101
+ .rich-text img {
102
+ max-width: 100%;
103
+ height: auto;
104
+ }
105
+ .rich-text figure {
106
+ margin: 1rem 0;
107
+ }
108
+
109
+ /* CTA button (inserted from the editor). Colors / radius / font come from the
110
+ site theme via `--forge-*` on published sites, falling back to the admin
111
+ design tokens (`--primary`, `--radius`) in the editor. Alignment is inline
112
+ (text-align on the .rich-text-button-wrap), so it round-trips without CSS. */
113
+ .rich-text .rich-text-button-wrap {
114
+ margin: 1rem 0;
115
+ }
116
+ .rich-text .rich-text-button {
117
+ display: inline-flex;
118
+ align-items: center;
119
+ justify-content: center;
120
+ gap: 0.5rem;
121
+ padding: 0.625rem 1.25rem;
122
+ font-weight: 600;
123
+ line-height: 1.2;
124
+ text-decoration: none;
125
+ border: 1px solid transparent;
126
+ border-radius: var(--forge-radius, var(--radius, 0.5rem));
127
+ font-family: var(--forge-font, inherit);
128
+ cursor: pointer;
129
+ transition: opacity 0.15s ease, background 0.15s ease;
130
+ }
131
+ .rich-text .rich-text-button:hover {
132
+ opacity: 0.9;
133
+ }
134
+ .rich-text .rich-text-button--filled {
135
+ background: var(--forge-primary, hsl(var(--primary, 262 83% 58%)));
136
+ color: var(--forge-text-primary, hsl(var(--primary-foreground, 0 0% 100%)));
137
+ }
138
+ .rich-text .rich-text-button--outline {
139
+ background: transparent;
140
+ color: var(--forge-primary, hsl(var(--primary, 262 83% 58%)));
141
+ border-color: var(--forge-primary, hsl(var(--primary, 262 83% 58%)));
142
+ }
143
+ .rich-text .rich-text-button--ghost {
144
+ background: transparent;
145
+ color: var(--forge-primary, hsl(var(--primary, 262 83% 58%)));
146
+ }
147
+ .rich-text .rich-text-button--ghost:hover {
148
+ opacity: 1;
149
+ background: color-mix(in srgb, var(--forge-primary, hsl(var(--primary, 262 83% 58%))) 10%, transparent);
150
+ }