@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,392 @@
1
+ import { useState, type CSSProperties } from "react";
2
+ import { Bookmark, Heart, Lock, MessageCircle, Pause, Play, FileText, BarChart2 } from "lucide-react";
3
+ import type { IMedia, IPublicPost } from "../../../types/models";
4
+ import { useAudioPlayer } from "../../../contexts/AudioPlayerContext";
5
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
6
+ import { usePostItem, type UsePostItemOptions } from "../../headless/posts/usePostItem";
7
+
8
+ export interface MemberPostCardProps extends UsePostItemOptions {
9
+ post: IPublicPost;
10
+ /** SPA navigation adapter (upsell CTA). Defaults to a full-page visit. */
11
+ navigate?: (href: string) => void;
12
+ /** Hide the artist header row (e.g. inside a collection). */
13
+ hideHeader?: boolean;
14
+ className?: string;
15
+ style?: CSSProperties;
16
+ }
17
+
18
+ /**
19
+ * One member post — parity with the craft members area's `PostItem`: artist
20
+ * header, caption, media (image / video / audio via the Forge audio player /
21
+ * document), like / save / comment engagement, and the locked treatment with
22
+ * an upgrade CTA (which tier unlocks + membership checkout deep link). Built
23
+ * on the headless `usePostItem`.
24
+ */
25
+ export function MemberPostCard({
26
+ post,
27
+ navigate,
28
+ hideHeader,
29
+ className,
30
+ style,
31
+ ...itemOptions
32
+ }: MemberPostCardProps) {
33
+ const t = useThemeTokens();
34
+ const item = usePostItem(post, itemOptions);
35
+ const { engagement } = item;
36
+ const go = navigate ?? ((href: string) => window.location.assign(href));
37
+ const [commentDraft, setCommentDraft] = useState("");
38
+
39
+ const separator = { height: 1, background: t.border } as const;
40
+
41
+ const actionButton = (
42
+ icon: React.ReactNode,
43
+ onClick: () => void,
44
+ count?: number,
45
+ label?: string,
46
+ pressed?: boolean,
47
+ ) => (
48
+ <button
49
+ type="button"
50
+ onClick={onClick}
51
+ aria-label={label}
52
+ aria-pressed={pressed}
53
+ style={{
54
+ display: "flex",
55
+ alignItems: "center",
56
+ gap: 4,
57
+ padding: 8,
58
+ borderRadius: 4,
59
+ border: "none",
60
+ background: "transparent",
61
+ cursor: "pointer",
62
+ color: t.text,
63
+ fontSize: 13,
64
+ }}
65
+ >
66
+ {icon}
67
+ {count !== undefined && <span>{count}</span>}
68
+ </button>
69
+ );
70
+
71
+ return (
72
+ <article
73
+ className={className}
74
+ style={{
75
+ width: "100%",
76
+ borderRadius: t.cornerRadius,
77
+ background: t.surface,
78
+ border: `1px solid ${t.border}`,
79
+ overflow: "hidden",
80
+ color: t.text,
81
+ fontFamily: t.fontFamily,
82
+ ...style,
83
+ }}
84
+ >
85
+ {/* Header */}
86
+ {!hideHeader && itemOptions.artistName && (
87
+ <>
88
+ <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "12px 16px" }}>
89
+ <span
90
+ aria-hidden
91
+ style={{
92
+ width: 40,
93
+ height: 40,
94
+ borderRadius: "50%",
95
+ background: t.primary,
96
+ color: t.textPrimary,
97
+ display: "flex",
98
+ alignItems: "center",
99
+ justifyContent: "center",
100
+ fontWeight: 700,
101
+ }}
102
+ >
103
+ {itemOptions.artistName.charAt(0).toUpperCase()}
104
+ </span>
105
+ <span style={{ fontSize: 14, fontWeight: 700 }}>{itemOptions.artistName}</span>
106
+ {post.type === "poll" && (
107
+ <span
108
+ style={{
109
+ marginLeft: "auto",
110
+ display: "inline-flex",
111
+ alignItems: "center",
112
+ gap: 4,
113
+ fontSize: 12,
114
+ color: t.muted,
115
+ }}
116
+ >
117
+ <BarChart2 size={14} /> Poll
118
+ </span>
119
+ )}
120
+ </div>
121
+ <div style={separator} />
122
+ </>
123
+ )}
124
+
125
+ {/* Caption */}
126
+ {post.caption && <p style={{ padding: 16, margin: 0, fontSize: 14, lineHeight: 1.5 }}>{post.caption}</p>}
127
+
128
+ {/* Body: locked upsell / media */}
129
+ {item.kind === "locked" && <LockedBody item={item} go={go} />}
130
+ {item.kind === "media" && item.media && (
131
+ <MediaBody media={item.media} item={item} />
132
+ )}
133
+
134
+ <div style={separator} />
135
+
136
+ {/* Engagement bar */}
137
+ <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 12px" }}>
138
+ {actionButton(
139
+ <Heart size={18} fill={engagement.isLiked ? "currentColor" : "none"} />,
140
+ () => engagement.toggleLike(),
141
+ engagement.likeCount,
142
+ engagement.isLiked ? "Unlike" : "Like",
143
+ engagement.isLiked,
144
+ )}
145
+ {actionButton(
146
+ <MessageCircle size={18} />,
147
+ () => engagement.openComments(),
148
+ engagement.commentCount,
149
+ "Comments",
150
+ )}
151
+ {actionButton(
152
+ <Bookmark size={18} fill={engagement.isSaved ? "currentColor" : "none"} />,
153
+ () => engagement.toggleSave(),
154
+ undefined,
155
+ engagement.isSaved ? "Unsave" : "Save",
156
+ engagement.isSaved,
157
+ )}
158
+ </div>
159
+
160
+ {/* Comments (inline panel) */}
161
+ {engagement.showComments && (
162
+ <div style={{ borderTop: `1px solid ${t.border}`, padding: 16 }}>
163
+ <div style={{ display: "flex", flexDirection: "column", gap: 12, maxHeight: 320, overflowY: "auto" }}>
164
+ {engagement.comments.length === 0 && (
165
+ <span style={{ fontSize: 13, color: t.muted }}>No comments yet.</span>
166
+ )}
167
+ {engagement.comments.map((comment) => (
168
+ <div key={comment.id}>
169
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
170
+ <span style={{ fontSize: 13, fontWeight: 600 }}>{comment.fullName || "Member"}</span>
171
+ <span style={{ fontSize: 11, color: t.muted }}>
172
+ {new Date(comment.createdAt).toLocaleDateString()}
173
+ </span>
174
+ </div>
175
+ <p style={{ margin: "2px 0 0", fontSize: 13 }}>{comment.content}</p>
176
+ </div>
177
+ ))}
178
+ </div>
179
+ <div style={{ display: "flex", gap: 8, marginTop: 12 }}>
180
+ <input
181
+ value={commentDraft}
182
+ onChange={(e) => setCommentDraft(e.target.value)}
183
+ placeholder="Add a comment…"
184
+ onKeyDown={async (e) => {
185
+ if (e.key === "Enter" && commentDraft.trim() && !engagement.isAddingComment) {
186
+ await engagement.addComment(commentDraft);
187
+ setCommentDraft("");
188
+ }
189
+ }}
190
+ style={{
191
+ flex: 1,
192
+ padding: "8px 12px",
193
+ borderRadius: t.cornerRadius,
194
+ border: `1px solid ${t.border}`,
195
+ background: t.background,
196
+ color: t.text,
197
+ fontSize: 13,
198
+ outline: "none",
199
+ }}
200
+ />
201
+ <button
202
+ type="button"
203
+ disabled={engagement.isAddingComment || !commentDraft.trim()}
204
+ onClick={async () => {
205
+ await engagement.addComment(commentDraft);
206
+ setCommentDraft("");
207
+ }}
208
+ style={{
209
+ padding: "8px 14px",
210
+ borderRadius: t.cornerRadius,
211
+ border: "none",
212
+ background: t.primary,
213
+ color: t.textPrimary,
214
+ fontWeight: 600,
215
+ fontSize: 13,
216
+ cursor: engagement.isAddingComment || !commentDraft.trim() ? "not-allowed" : "pointer",
217
+ opacity: engagement.isAddingComment || !commentDraft.trim() ? 0.6 : 1,
218
+ }}
219
+ >
220
+ {engagement.isAddingComment ? "Posting…" : "Post"}
221
+ </button>
222
+ </div>
223
+ </div>
224
+ )}
225
+ </article>
226
+ );
227
+ }
228
+
229
+ function LockedBody({ item, go }: { item: ReturnType<typeof usePostItem>; go: (href: string) => void }) {
230
+ const t = useThemeTokens();
231
+ const tierName = item.upsell.tier?.name;
232
+ return (
233
+ <div
234
+ style={{
235
+ display: "flex",
236
+ flexDirection: "column",
237
+ alignItems: "center",
238
+ gap: 12,
239
+ padding: "40px 16px",
240
+ textAlign: "center",
241
+ background: `${t.primary}0d`,
242
+ }}
243
+ >
244
+ <span
245
+ style={{
246
+ width: 56,
247
+ height: 56,
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={24} />
257
+ </span>
258
+ <div>
259
+ <div style={{ fontSize: 16, fontWeight: 700 }}>Content locked</div>
260
+ <div style={{ fontSize: 13, color: t.muted, marginTop: 2 }}>
261
+ {tierName ? `Subscribe to ${tierName} to access this content` : "Subscribe to access this content"}
262
+ </div>
263
+ </div>
264
+ <button
265
+ type="button"
266
+ onClick={() => go(item.upsell.href)}
267
+ style={{
268
+ padding: "10px 18px",
269
+ borderRadius: t.cornerRadius,
270
+ border: "none",
271
+ background: t.primary,
272
+ color: t.textPrimary,
273
+ fontWeight: 600,
274
+ cursor: "pointer",
275
+ }}
276
+ >
277
+ Subscribe
278
+ </button>
279
+ </div>
280
+ );
281
+ }
282
+
283
+ function MediaBody({ media, item }: { media: IMedia; item: ReturnType<typeof usePostItem> }) {
284
+ const t = useThemeTokens();
285
+
286
+ if (media.type === "image") {
287
+ return (
288
+ <img
289
+ src={media.url}
290
+ alt={media.filename ?? ""}
291
+ style={{ width: "100%", maxHeight: "70vh", objectFit: "cover", display: "block" }}
292
+ />
293
+ );
294
+ }
295
+
296
+ if (media.type === "video") {
297
+ return (
298
+ <video src={media.url} controls playsInline style={{ width: "100%", maxHeight: "70vh", display: "block", background: "#000" }} />
299
+ );
300
+ }
301
+
302
+ if (media.type === "audio" && item.audioTrack) {
303
+ return <AudioBody item={item} />;
304
+ }
305
+
306
+ if (media.type === "document") {
307
+ return (
308
+ <a
309
+ href={media.url}
310
+ target="_blank"
311
+ rel="noreferrer"
312
+ style={{
313
+ display: "flex",
314
+ alignItems: "center",
315
+ gap: 12,
316
+ margin: 16,
317
+ padding: 16,
318
+ borderRadius: t.cornerRadius,
319
+ background: `${t.primary}0d`,
320
+ color: t.text,
321
+ textDecoration: "none",
322
+ }}
323
+ >
324
+ <span
325
+ style={{
326
+ width: 40,
327
+ height: 40,
328
+ borderRadius: 6,
329
+ background: t.primary,
330
+ color: t.textPrimary,
331
+ display: "flex",
332
+ alignItems: "center",
333
+ justifyContent: "center",
334
+ }}
335
+ >
336
+ <FileText size={20} />
337
+ </span>
338
+ <span style={{ minWidth: 0 }}>
339
+ <span style={{ display: "block", fontSize: 14, fontWeight: 500 }}>{media.filename ?? "Document"}</span>
340
+ <span style={{ display: "block", fontSize: 12, color: t.muted }}>Document</span>
341
+ </span>
342
+ </a>
343
+ );
344
+ }
345
+
346
+ return null;
347
+ }
348
+
349
+ function AudioBody({ item }: { item: ReturnType<typeof usePostItem> }) {
350
+ const t = useThemeTokens();
351
+ const player = useAudioPlayer();
352
+ const track = item.audioTrack!;
353
+ const isCurrent = player.currentTrack?.id === track.id;
354
+ const playing = isCurrent && player.isPlaying;
355
+
356
+ const onClick = () => {
357
+ if (playing) return player.pause();
358
+ if (isCurrent) return player.play();
359
+ player.loadAndPlay(track, item.audioQueue);
360
+ };
361
+
362
+ return (
363
+ <div style={{ display: "flex", alignItems: "center", gap: 16, margin: 16, padding: 16, borderRadius: t.cornerRadius, background: `${t.primary}0d` }}>
364
+ <button
365
+ type="button"
366
+ onClick={onClick}
367
+ aria-label={playing ? "Pause" : "Play"}
368
+ style={{
369
+ width: 48,
370
+ height: 48,
371
+ borderRadius: "50%",
372
+ border: "none",
373
+ background: t.primary,
374
+ color: t.textPrimary,
375
+ display: "flex",
376
+ alignItems: "center",
377
+ justifyContent: "center",
378
+ cursor: "pointer",
379
+ flexShrink: 0,
380
+ }}
381
+ >
382
+ {playing ? <Pause size={20} /> : <Play size={20} style={{ marginLeft: 2 }} />}
383
+ </button>
384
+ <div style={{ minWidth: 0 }}>
385
+ <div style={{ fontSize: 14, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
386
+ {item.media?.filename || track.title}
387
+ </div>
388
+ <div style={{ fontSize: 12, color: t.muted }}>Audio track</div>
389
+ </div>
390
+ </div>
391
+ );
392
+ }
@@ -0,0 +1,219 @@
1
+ import { useState, type CSSProperties } from "react";
2
+ import { Search, X } from "lucide-react";
3
+ import type { PostType } from "../../../types/models";
4
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
5
+ import { useMemberPostsFeed, type UseMemberPostsFeedOptions } from "../../headless/posts/useMemberPostsFeed";
6
+ import { Loading } from "../Loading";
7
+ import { MemberPostCard } from "./MemberPostCard";
8
+
9
+ export interface MemberPostsFeedProps extends UseMemberPostsFeedOptions {
10
+ /** Shown in each post's header + as the audio track artist. */
11
+ artistName?: string;
12
+ /** SPA navigation adapter (locked-post upsell CTA). Defaults to a full-page visit. */
13
+ navigate?: (href: string) => void;
14
+ /** Called when a logged-out visitor tries to like/save/comment (default: navigate to `/login`). */
15
+ onRequireAuth?: () => void;
16
+ /** Login path for the default require-auth redirect (site-starter passes `/i/login`). */
17
+ loginPath?: string;
18
+ className?: string;
19
+ style?: CSSProperties;
20
+ }
21
+
22
+ const POST_TYPES: Array<{ value: PostType; label: string }> = [
23
+ { value: "image", label: "Image" },
24
+ { value: "video", label: "Video" },
25
+ { value: "audio", label: "Audio" },
26
+ { value: "poll", label: "Poll" },
27
+ ];
28
+
29
+ /**
30
+ * The member posts feed — parity with the craft members area: gated feed
31
+ * (server-side `hasAccess`, locked posts render an upgrade CTA), debounced
32
+ * search, post-type + membership-tier filters, saved-posts view, engagement
33
+ * on every card, and load-more paging. Built on the headless
34
+ * `useMemberPostsFeed` / `usePostItem`.
35
+ */
36
+ export function MemberPostsFeed({
37
+ artistName,
38
+ navigate,
39
+ onRequireAuth,
40
+ loginPath = "/login",
41
+ className,
42
+ style,
43
+ ...feedOptions
44
+ }: MemberPostsFeedProps) {
45
+ const t = useThemeTokens();
46
+ const feed = useMemberPostsFeed(feedOptions);
47
+ const go = navigate ?? ((href: string) => window.location.assign(href));
48
+ const requireAuth =
49
+ onRequireAuth ??
50
+ (() => {
51
+ const redirect = typeof window !== "undefined" ? `${window.location.pathname}${window.location.search}` : "";
52
+ go(redirect ? `${loginPath}?redirect=${encodeURIComponent(redirect)}` : loginPath);
53
+ });
54
+ const [searchFocused, setSearchFocused] = useState(false);
55
+
56
+ const chip = (label: string, selected: boolean, onClick: () => void) => (
57
+ <button
58
+ key={label}
59
+ type="button"
60
+ onClick={onClick}
61
+ aria-pressed={selected}
62
+ style={{
63
+ padding: "6px 12px",
64
+ borderRadius: 999,
65
+ border: `1px solid ${selected ? t.primary : t.border}`,
66
+ background: selected ? t.primary : "transparent",
67
+ color: selected ? t.textPrimary : t.text,
68
+ fontSize: 13,
69
+ fontWeight: selected ? 600 : 400,
70
+ cursor: "pointer",
71
+ whiteSpace: "nowrap",
72
+ }}
73
+ >
74
+ {label}
75
+ </button>
76
+ );
77
+
78
+ return (
79
+ <div
80
+ className={className}
81
+ style={{ display: "flex", flexDirection: "column", gap: 16, color: t.text, fontFamily: t.fontFamily, ...style }}
82
+ >
83
+ {/* Search */}
84
+ <div
85
+ style={{
86
+ display: "flex",
87
+ alignItems: "center",
88
+ gap: 8,
89
+ padding: "10px 14px",
90
+ borderRadius: t.cornerRadius,
91
+ border: `1px solid ${searchFocused ? t.primary : t.border}`,
92
+ background: t.surface,
93
+ }}
94
+ >
95
+ <Search size={16} style={{ color: t.muted, flexShrink: 0 }} />
96
+ <input
97
+ value={feed.search}
98
+ onChange={(e) => feed.setSearch(e.target.value)}
99
+ onFocus={() => setSearchFocused(true)}
100
+ onBlur={() => setSearchFocused(false)}
101
+ placeholder="Search posts…"
102
+ style={{ flex: 1, border: "none", outline: "none", background: "transparent", color: t.text, fontSize: 14 }}
103
+ />
104
+ {feed.search && (
105
+ <button
106
+ type="button"
107
+ aria-label="Clear search"
108
+ onClick={() => feed.setSearch("")}
109
+ style={{ border: "none", background: "transparent", color: t.muted, cursor: "pointer", display: "flex" }}
110
+ >
111
+ <X size={14} />
112
+ </button>
113
+ )}
114
+ </div>
115
+
116
+ {/* Filters: type chips + tier select + saved toggle */}
117
+ <div className="flex flex-wrap items-center gap-2">
118
+ {chip("All", !feed.type && !feed.savedOnly, () => {
119
+ feed.setType("");
120
+ feed.setSavedOnly(false);
121
+ })}
122
+ {POST_TYPES.map((pt) =>
123
+ chip(pt.label, feed.type === pt.value && !feed.savedOnly, () => {
124
+ feed.setSavedOnly(false);
125
+ feed.setType(feed.type === pt.value ? "" : pt.value);
126
+ }),
127
+ )}
128
+ {feed.isAuthenticated && chip("Saved", feed.savedOnly, () => feed.setSavedOnly(!feed.savedOnly))}
129
+ {feed.tiers.length > 0 && !feed.savedOnly && (
130
+ <select
131
+ value={feed.membershipTierId}
132
+ onChange={(e) => feed.setMembershipTierId(e.target.value)}
133
+ aria-label="Filter by membership tier"
134
+ style={{
135
+ marginLeft: "auto",
136
+ padding: "6px 10px",
137
+ borderRadius: t.cornerRadius,
138
+ border: `1px solid ${feed.membershipTierId ? t.primary : t.border}`,
139
+ background: t.surface,
140
+ color: t.text,
141
+ fontSize: 13,
142
+ cursor: "pointer",
143
+ }}
144
+ >
145
+ <option value="">All tiers</option>
146
+ {feed.tiers.map((tier) => (
147
+ <option key={tier.id} value={tier.id}>
148
+ {tier.name}
149
+ </option>
150
+ ))}
151
+ </select>
152
+ )}
153
+ {feed.hasActiveFilters && (
154
+ <button
155
+ type="button"
156
+ onClick={feed.clearFilters}
157
+ style={{
158
+ border: "none",
159
+ background: "transparent",
160
+ color: t.primary,
161
+ fontSize: 13,
162
+ textDecoration: "underline",
163
+ cursor: "pointer",
164
+ }}
165
+ >
166
+ Clear all
167
+ </button>
168
+ )}
169
+ </div>
170
+
171
+ {/* Feed */}
172
+ {feed.isLoading ? (
173
+ <Loading />
174
+ ) : feed.posts.length === 0 ? (
175
+ <p style={{ textAlign: "center", padding: "40px 20px", color: t.muted, fontSize: 15 }}>
176
+ {feed.hasActiveFilters ? "No posts found matching your filters" : "No posts yet"}
177
+ </p>
178
+ ) : (
179
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
180
+ {feed.posts.map((post) => (
181
+ <MemberPostCard
182
+ key={post.id}
183
+ post={post}
184
+ artistName={artistName}
185
+ otherAudioPosts={feed.audioPosts.filter((p) => p.id !== post.id)}
186
+ navigate={go}
187
+ onRequireAuth={requireAuth}
188
+ checkoutPath={feedOptions.checkoutPath}
189
+ signupPath={feedOptions.signupPath}
190
+ />
191
+ ))}
192
+ </div>
193
+ )}
194
+
195
+ {/* Load more */}
196
+ {feed.hasMore && (
197
+ <button
198
+ type="button"
199
+ onClick={feed.loadMore}
200
+ disabled={feed.isLoadingMore}
201
+ style={{
202
+ alignSelf: "center",
203
+ padding: "10px 24px",
204
+ borderRadius: t.cornerRadius,
205
+ border: `1px solid ${t.primary}`,
206
+ background: "transparent",
207
+ color: t.primary,
208
+ fontWeight: 600,
209
+ fontSize: 14,
210
+ cursor: feed.isLoadingMore ? "wait" : "pointer",
211
+ opacity: feed.isLoadingMore ? 0.6 : 1,
212
+ }}
213
+ >
214
+ {feed.isLoadingMore ? "Loading…" : "Load more"}
215
+ </button>
216
+ )}
217
+ </div>
218
+ );
219
+ }