@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,116 @@
1
+ import { useState } from "react";
2
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
3
+ import { formatBytes } from "../../headless/chat/attachmentKind";
4
+ import type { CommunityMediaItem } from "../../../data/queries/useCommunity";
5
+
6
+ /**
7
+ * Renders a post/reply's attached media (from media_mappings) — images open a
8
+ * lightbox, video/audio get players, documents/other get a download card.
9
+ * Same presentation rules as chat's <ChatAttachments/>.
10
+ */
11
+ export function CommunityMediaGallery({ media }: { media: CommunityMediaItem[] }) {
12
+ const t = useThemeTokens();
13
+ const [lightbox, setLightbox] = useState<string | null>(null);
14
+ if (!media?.length) return null;
15
+
16
+ const images = media.filter((m) => m.type === "image");
17
+ const rest = media.filter((m) => m.type !== "image");
18
+
19
+ return (
20
+ <div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
21
+ {images.length > 0 && (
22
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
23
+ {images.map((m) => (
24
+ <img
25
+ key={m.id}
26
+ src={m.url}
27
+ alt={m.name ?? ""}
28
+ onClick={() => setLightbox(m.url)}
29
+ style={{
30
+ maxWidth: images.length > 1 ? 180 : 340,
31
+ maxHeight: images.length > 1 ? 180 : 340,
32
+ borderRadius: t.cornerRadius,
33
+ cursor: "zoom-in",
34
+ objectFit: "cover",
35
+ }}
36
+ />
37
+ ))}
38
+ </div>
39
+ )}
40
+
41
+ {rest.map((m) => {
42
+ if (m.type === "video") {
43
+ return (
44
+ <video key={m.id} src={m.url} controls preload="metadata" style={{ maxWidth: 340, borderRadius: t.cornerRadius }} />
45
+ );
46
+ }
47
+ if (m.type === "audio") {
48
+ return (
49
+ <div key={m.id} style={{ display: "flex", flexDirection: "column", gap: 4, maxWidth: 340 }}>
50
+ {m.name && <span style={{ fontSize: 12, color: t.muted }}>{m.name}</span>}
51
+ <audio src={m.url} controls preload="metadata" style={{ width: "100%" }} />
52
+ </div>
53
+ );
54
+ }
55
+ return (
56
+ <div
57
+ key={m.id}
58
+ style={{
59
+ display: "flex",
60
+ alignItems: "center",
61
+ gap: 10,
62
+ padding: "8px 10px",
63
+ border: `1px solid ${t.border}`,
64
+ borderRadius: t.cornerRadius,
65
+ maxWidth: 340,
66
+ }}
67
+ >
68
+ <span style={{ fontSize: 18 }} aria-hidden>
69
+ 📄
70
+ </span>
71
+ <div style={{ minWidth: 0, flex: 1 }}>
72
+ <div style={{ fontSize: 13, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
73
+ {m.name ?? "Attachment"}
74
+ </div>
75
+ {m.size != null && <div style={{ fontSize: 11, color: t.muted }}>{formatBytes(Number(m.size))}</div>}
76
+ </div>
77
+ <a
78
+ href={m.url}
79
+ target="_blank"
80
+ rel="noopener noreferrer"
81
+ download
82
+ style={{
83
+ background: t.primary,
84
+ color: t.textPrimary,
85
+ borderRadius: 6,
86
+ padding: "2px 8px",
87
+ fontSize: 12,
88
+ textDecoration: "none",
89
+ }}
90
+ >
91
+ Download
92
+ </a>
93
+ </div>
94
+ );
95
+ })}
96
+
97
+ {lightbox && (
98
+ <div
99
+ onClick={() => setLightbox(null)}
100
+ style={{
101
+ position: "fixed",
102
+ inset: 0,
103
+ background: "rgba(0,0,0,0.8)",
104
+ display: "flex",
105
+ alignItems: "center",
106
+ justifyContent: "center",
107
+ zIndex: 1000,
108
+ cursor: "zoom-out",
109
+ }}
110
+ >
111
+ <img src={lightbox} alt="" style={{ maxWidth: "90vw", maxHeight: "90vh", objectFit: "contain" }} />
112
+ </div>
113
+ )}
114
+ </div>
115
+ );
116
+ }
@@ -0,0 +1,285 @@
1
+ import { useRef, useState } from "react";
2
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
3
+ import { usePublicAuth } from "../../../contexts/PublicAuthContext";
4
+ import {
5
+ communityMemberName,
6
+ useCommunityMemberProfile,
7
+ useMyCommunityProfile,
8
+ useUpdateMyCommunityProfile,
9
+ type CommunityDigestCadence,
10
+ type CommunityMemberProfile as MemberProfileData,
11
+ } from "../../../data/queries/useCommunity";
12
+ import { useCommunityAttachmentUpload } from "../../headless/community/useCommunityAttachmentUpload";
13
+ import { Loading } from "../Loading";
14
+ import { MemberAvatar } from "./MemberAvatar";
15
+ import { timeAgo } from "./util";
16
+
17
+ const DIGEST_OPTIONS: { value: CommunityDigestCadence; label: string; hint: string }[] = [
18
+ { value: "daily", label: "Daily digest", hint: "One email a day with what you missed" },
19
+ { value: "hourly", label: "Hourly digest", hint: "Batched email roughly every hour" },
20
+ { value: "inapp_only", label: "In-app only", hint: "Notifications stay in your inbox here" },
21
+ { value: "off", label: "Off", hint: "No notifications at all" },
22
+ ];
23
+
24
+ export interface CommunityMemberProfileProps {
25
+ /** The member to show. When it's the signed-in member, editing + the email
26
+ * digest cadence preference are unlocked. */
27
+ accountId: string;
28
+ onOpenPost?: (postId: string) => void;
29
+ onBack?: () => void;
30
+ }
31
+
32
+ /** Member profile: avatar, bio, points/level, recent activity — plus settings
33
+ * (display name, bio, avatar, digest cadence) on your own profile. */
34
+ export function CommunityMemberProfile({ accountId, onOpenPost, onBack }: CommunityMemberProfileProps) {
35
+ const t = useThemeTokens();
36
+ const { user } = usePublicAuth();
37
+ const isSelf = user?.id === accountId;
38
+
39
+ // `me` also bootstraps the community profile row on first visit.
40
+ const myProfile = useMyCommunityProfile(isSelf);
41
+ const otherProfile = useCommunityMemberProfile(isSelf ? undefined : accountId);
42
+ const profile = isSelf ? myProfile.data : otherProfile.data;
43
+ const isLoading = isSelf ? myProfile.isLoading : otherProfile.isLoading;
44
+
45
+ const border = `1px solid ${t.border}`;
46
+
47
+ if (isLoading) return <Loading />;
48
+ if (!profile) {
49
+ return (
50
+ <div style={{ border, borderRadius: t.cornerRadius, padding: 32, textAlign: "center", color: t.muted }}>
51
+ This member hasn't joined the community yet.
52
+ </div>
53
+ );
54
+ }
55
+
56
+ return (
57
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
58
+ {onBack && (
59
+ <button
60
+ onClick={onBack}
61
+ style={{ alignSelf: "flex-start", background: "none", border: "none", cursor: "pointer", color: t.text, fontSize: 14, padding: 0 }}
62
+ >
63
+ ← Back
64
+ </button>
65
+ )}
66
+
67
+ {/* Identity card */}
68
+ <div style={{ border, borderRadius: t.cornerRadius, background: t.surface, padding: 18, display: "flex", gap: 14, alignItems: "center" }}>
69
+ <MemberAvatar member={profile} size={64} />
70
+ <div style={{ flex: 1, minWidth: 0 }}>
71
+ <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
72
+ <h2 style={{ margin: 0, fontSize: 19, fontFamily: t.headingFontFamily, color: t.text }}>
73
+ {communityMemberName(profile)}
74
+ </h2>
75
+ {profile.role === "moderator" && (
76
+ <span style={{ fontSize: 11, fontWeight: 700, color: t.primary, textTransform: "uppercase" }}>Moderator</span>
77
+ )}
78
+ </div>
79
+ {profile.bio && <p style={{ margin: "6px 0 0", fontSize: 14, color: t.text }}>{profile.bio}</p>}
80
+ {profile.lastActiveAt && (
81
+ <p style={{ margin: "4px 0 0", fontSize: 12, color: t.muted }}>Active {timeAgo(profile.lastActiveAt)} ago</p>
82
+ )}
83
+ </div>
84
+ <div style={{ textAlign: "center", flexShrink: 0 }}>
85
+ <div style={{ fontSize: 22, fontWeight: 800, color: t.primary }}>{profile.points}</div>
86
+ <div style={{ fontSize: 11, color: t.muted }}>points</div>
87
+ <div
88
+ style={{
89
+ marginTop: 4,
90
+ background: `${t.primary}1a`,
91
+ color: t.primary,
92
+ borderRadius: 999,
93
+ fontSize: 11,
94
+ fontWeight: 700,
95
+ padding: "1px 8px",
96
+ }}
97
+ >
98
+ Level {profile.level}
99
+ </div>
100
+ </div>
101
+ </div>
102
+
103
+ {isSelf && <ProfileSettings profile={profile} />}
104
+
105
+ {/* Activity */}
106
+ {(profile.recentPosts?.length || profile.recentReplies?.length) ? (
107
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
108
+ <h3 style={{ margin: 0, fontSize: 15, color: t.text }}>Recent activity</h3>
109
+ {(profile.recentPosts ?? []).map((p) => (
110
+ <button
111
+ key={`post-${p.id}`}
112
+ onClick={() => onOpenPost?.(p.id)}
113
+ style={{
114
+ border,
115
+ borderRadius: t.cornerRadius,
116
+ background: t.surface,
117
+ padding: "10px 12px",
118
+ textAlign: "left",
119
+ cursor: onOpenPost ? "pointer" : "default",
120
+ color: t.text,
121
+ }}
122
+ >
123
+ <div style={{ fontSize: 12, color: t.muted, marginBottom: 2 }}>Posted · {timeAgo(p.createdAt)}</div>
124
+ <div style={{ fontWeight: 600, fontSize: 14 }}>{p.title || p.bodyText?.slice(0, 100) || "Post"}</div>
125
+ <div style={{ fontSize: 12, color: t.muted, marginTop: 2 }}>
126
+ {p.replyCount} replies · {p.reactionCount} reactions
127
+ </div>
128
+ </button>
129
+ ))}
130
+ {(profile.recentReplies ?? []).map((r) => (
131
+ <button
132
+ key={`reply-${r.id}`}
133
+ onClick={() => onOpenPost?.(r.postId)}
134
+ style={{
135
+ border,
136
+ borderRadius: t.cornerRadius,
137
+ background: t.surface,
138
+ padding: "10px 12px",
139
+ textAlign: "left",
140
+ cursor: onOpenPost ? "pointer" : "default",
141
+ color: t.text,
142
+ }}
143
+ >
144
+ <div style={{ fontSize: 12, color: t.muted, marginBottom: 2 }}>
145
+ Replied{r.postTitle ? ` to "${r.postTitle}"` : ""} · {timeAgo(r.createdAt)}
146
+ </div>
147
+ <div style={{ fontSize: 14 }}>{r.bodyText?.slice(0, 140)}</div>
148
+ </button>
149
+ ))}
150
+ </div>
151
+ ) : (
152
+ <div style={{ border, borderRadius: t.cornerRadius, padding: 20, textAlign: "center", color: t.muted, fontSize: 14 }}>
153
+ No activity yet.
154
+ </div>
155
+ )}
156
+ </div>
157
+ );
158
+ }
159
+
160
+ function ProfileSettings({ profile }: { profile: MemberProfileData }) {
161
+ const t = useThemeTokens();
162
+ const update = useUpdateMyCommunityProfile();
163
+ const upload = useCommunityAttachmentUpload();
164
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
165
+
166
+ const [displayName, setDisplayName] = useState(profile.displayName ?? "");
167
+ const [bio, setBio] = useState(profile.bio ?? "");
168
+ const [saved, setSaved] = useState(false);
169
+
170
+ const border = `1px solid ${t.border}`;
171
+ const inputStyle: React.CSSProperties = {
172
+ width: "100%",
173
+ boxSizing: "border-box",
174
+ padding: "8px 10px",
175
+ borderRadius: t.cornerRadius,
176
+ border,
177
+ background: t.background,
178
+ color: t.text,
179
+ fontFamily: "inherit",
180
+ fontSize: 14,
181
+ };
182
+
183
+ const save = (extra: Partial<{ avatarUrl: string | null; digestCadence: CommunityDigestCadence }> = {}) => {
184
+ setSaved(false);
185
+ update.mutate(
186
+ {
187
+ displayName: displayName.trim() || null,
188
+ bio: bio.trim() || null,
189
+ ...extra,
190
+ },
191
+ { onSuccess: () => setSaved(true) },
192
+ );
193
+ };
194
+
195
+ const onAvatarPicked = async (files: FileList) => {
196
+ upload.clear();
197
+ upload.addFiles([files[0]]);
198
+ // Upload immediately; addFiles is sync state, uploadAll reads the ref on next tick.
199
+ setTimeout(async () => {
200
+ try {
201
+ const [attachment] = await upload.uploadAll();
202
+ if (attachment) save({ avatarUrl: attachment.url });
203
+ upload.clear();
204
+ } catch {
205
+ // upload.error carries the message
206
+ }
207
+ }, 0);
208
+ };
209
+
210
+ return (
211
+ <div style={{ border, borderRadius: t.cornerRadius, background: t.surface, padding: 16, display: "flex", flexDirection: "column", gap: 12 }}>
212
+ <h3 style={{ margin: 0, fontSize: 15, color: t.text }}>Your community settings</h3>
213
+
214
+ <label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13, color: t.muted }}>
215
+ Display name
216
+ <input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={80} placeholder="How other members see you" style={inputStyle} />
217
+ </label>
218
+
219
+ <label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13, color: t.muted }}>
220
+ Bio
221
+ <textarea value={bio} onChange={(e) => setBio(e.target.value)} maxLength={1000} rows={3} placeholder="A line or two about you" style={{ ...inputStyle, resize: "vertical" }} />
222
+ </label>
223
+
224
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
225
+ <input
226
+ ref={fileInputRef}
227
+ type="file"
228
+ accept="image/*"
229
+ hidden
230
+ onChange={(e) => {
231
+ if (e.target.files?.length) onAvatarPicked(e.target.files);
232
+ e.target.value = "";
233
+ }}
234
+ />
235
+ <button
236
+ onClick={() => fileInputRef.current?.click()}
237
+ disabled={upload.isUploading}
238
+ style={{ background: "none", border, borderRadius: t.cornerRadius, padding: "6px 12px", cursor: "pointer", color: t.text, fontSize: 13 }}
239
+ >
240
+ {upload.isUploading ? "Uploading…" : "Change avatar"}
241
+ </button>
242
+ {upload.error && <span style={{ fontSize: 12, color: "#dc2626" }}>{upload.error}</span>}
243
+ </div>
244
+
245
+ <fieldset style={{ border: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 6 }}>
246
+ <legend style={{ fontSize: 13, color: t.muted, padding: 0, marginBottom: 4 }}>Email digest</legend>
247
+ {DIGEST_OPTIONS.map((opt) => (
248
+ <label key={opt.value} style={{ display: "flex", alignItems: "flex-start", gap: 8, cursor: "pointer", fontSize: 14, color: t.text }}>
249
+ <input
250
+ type="radio"
251
+ name="digest-cadence"
252
+ checked={profile.digestCadence === opt.value}
253
+ onChange={() => save({ digestCadence: opt.value })}
254
+ style={{ marginTop: 3, accentColor: t.primary }}
255
+ />
256
+ <span>
257
+ {opt.label}
258
+ <span style={{ display: "block", fontSize: 12, color: t.muted }}>{opt.hint}</span>
259
+ </span>
260
+ </label>
261
+ ))}
262
+ </fieldset>
263
+
264
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
265
+ <button
266
+ onClick={() => save()}
267
+ disabled={update.isPending}
268
+ style={{
269
+ background: t.primary,
270
+ color: t.textPrimary,
271
+ border: "none",
272
+ borderRadius: t.cornerRadius,
273
+ padding: "8px 18px",
274
+ fontWeight: 600,
275
+ cursor: update.isPending ? "wait" : "pointer",
276
+ fontSize: 14,
277
+ }}
278
+ >
279
+ {update.isPending ? "Saving…" : "Save"}
280
+ </button>
281
+ {saved && !update.isPending && <span style={{ fontSize: 13, color: t.muted }}>Saved ✓</span>}
282
+ </div>
283
+ </div>
284
+ );
285
+ }
@@ -0,0 +1,222 @@
1
+ import { AtSign, Bell, Heart, MessageCircle, ShieldAlert, Sparkles } from "lucide-react";
2
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
3
+ import {
4
+ useCommunityNotifications,
5
+ useCommunityUnreadCount,
6
+ useMarkCommunityNotificationsRead,
7
+ type CommunityNotificationItem,
8
+ } from "../../../data/queries/useCommunity";
9
+ import { Loading } from "../Loading";
10
+ import { timeAgo } from "./util";
11
+
12
+ // ── Bell ─────────────────────────────────────────────────────────────────────
13
+
14
+ export interface CommunityNotificationsBellProps {
15
+ onClick: () => void;
16
+ size?: number;
17
+ }
18
+
19
+ /** Bell button with the unread badge (polled every 30s). */
20
+ export function CommunityNotificationsBell({ onClick, size = 20 }: CommunityNotificationsBellProps) {
21
+ const t = useThemeTokens();
22
+ const { data: unread = 0 } = useCommunityUnreadCount();
23
+
24
+ return (
25
+ <button
26
+ onClick={onClick}
27
+ aria-label={unread > 0 ? `Notifications (${unread} unread)` : "Notifications"}
28
+ style={{
29
+ position: "relative",
30
+ background: "none",
31
+ border: `1px solid ${t.border}`,
32
+ borderRadius: t.cornerRadius,
33
+ width: size + 16,
34
+ height: size + 16,
35
+ display: "inline-flex",
36
+ alignItems: "center",
37
+ justifyContent: "center",
38
+ cursor: "pointer",
39
+ color: t.text,
40
+ flexShrink: 0,
41
+ }}
42
+ >
43
+ <Bell size={size} />
44
+ {unread > 0 && (
45
+ <span
46
+ style={{
47
+ position: "absolute",
48
+ top: -6,
49
+ right: -6,
50
+ background: t.primary,
51
+ color: t.textPrimary,
52
+ borderRadius: 999,
53
+ fontSize: 10,
54
+ fontWeight: 700,
55
+ minWidth: 16,
56
+ height: 16,
57
+ padding: "0 4px",
58
+ display: "flex",
59
+ alignItems: "center",
60
+ justifyContent: "center",
61
+ }}
62
+ >
63
+ {unread > 99 ? "99+" : unread}
64
+ </span>
65
+ )}
66
+ </button>
67
+ );
68
+ }
69
+
70
+ // ── Inbox ────────────────────────────────────────────────────────────────────
71
+
72
+ const NOTIFICATION_COPY: Record<string, string> = {
73
+ mention: "You were mentioned",
74
+ reply: "New reply to your post",
75
+ reaction: "Someone reacted to your content",
76
+ new_post: "New post in the community",
77
+ mod: "A moderator updated your account",
78
+ };
79
+
80
+ function NotificationIcon({ type }: { type: string }) {
81
+ const props = { size: 16 };
82
+ switch (type) {
83
+ case "mention":
84
+ return <AtSign {...props} />;
85
+ case "reply":
86
+ return <MessageCircle {...props} />;
87
+ case "reaction":
88
+ return <Heart {...props} />;
89
+ case "new_post":
90
+ return <Sparkles {...props} />;
91
+ case "mod":
92
+ return <ShieldAlert {...props} />;
93
+ default:
94
+ return <Bell {...props} />;
95
+ }
96
+ }
97
+
98
+ export interface CommunityNotificationsInboxProps {
99
+ /** Post notifications (entityType community_post) navigate here. Reply
100
+ * notifications carry only the reply id — no public reply→post resolver yet,
101
+ * so they render without navigation. */
102
+ onOpenPost?: (postId: string) => void;
103
+ }
104
+
105
+ /** The in-app notification inbox with mark-all-read. */
106
+ export function CommunityNotificationsInbox({ onOpenPost }: CommunityNotificationsInboxProps) {
107
+ const t = useThemeTokens();
108
+ const { data, isLoading } = useCommunityNotifications();
109
+ const markRead = useMarkCommunityNotificationsRead();
110
+ const border = `1px solid ${t.border}`;
111
+
112
+ if (isLoading) return <Loading />;
113
+
114
+ const items = data?.data ?? [];
115
+ const unreadCount = data?.unreadCount ?? 0;
116
+
117
+ return (
118
+ <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
119
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
120
+ <h2 style={{ margin: 0, fontSize: 18, fontFamily: t.headingFontFamily, color: t.text }}>Notifications</h2>
121
+ {unreadCount > 0 && (
122
+ <button
123
+ onClick={() => markRead.mutate(undefined)}
124
+ disabled={markRead.isPending}
125
+ style={{
126
+ background: "none",
127
+ border,
128
+ borderRadius: t.cornerRadius,
129
+ padding: "4px 12px",
130
+ cursor: "pointer",
131
+ color: t.primary,
132
+ fontWeight: 600,
133
+ fontSize: 13,
134
+ }}
135
+ >
136
+ Mark all read
137
+ </button>
138
+ )}
139
+ </div>
140
+
141
+ {items.length === 0 ? (
142
+ <div style={{ border, borderRadius: t.cornerRadius, padding: 28, textAlign: "center", color: t.muted }}>
143
+ You're all caught up.
144
+ </div>
145
+ ) : (
146
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
147
+ {items.map((n) => (
148
+ <NotificationRow
149
+ key={n.id}
150
+ notification={n}
151
+ onOpen={
152
+ n.entityType === "community_post" && n.entityId && onOpenPost
153
+ ? () => {
154
+ if (!n.readAt) markRead.mutate([n.id]);
155
+ onOpenPost(n.entityId!);
156
+ }
157
+ : undefined
158
+ }
159
+ onMarkRead={!n.readAt ? () => markRead.mutate([n.id]) : undefined}
160
+ />
161
+ ))}
162
+ </div>
163
+ )}
164
+ </div>
165
+ );
166
+ }
167
+
168
+ function NotificationRow({
169
+ notification,
170
+ onOpen,
171
+ onMarkRead,
172
+ }: {
173
+ notification: CommunityNotificationItem;
174
+ onOpen?: () => void;
175
+ onMarkRead?: () => void;
176
+ }) {
177
+ const t = useThemeTokens();
178
+ const unread = !notification.readAt;
179
+
180
+ return (
181
+ <div
182
+ onClick={onOpen ?? onMarkRead}
183
+ role={onOpen ? "button" : undefined}
184
+ style={{
185
+ display: "flex",
186
+ alignItems: "center",
187
+ gap: 12,
188
+ border: `1px solid ${unread ? `${t.primary}55` : t.border}`,
189
+ background: unread ? `${t.primary}0d` : t.surface,
190
+ borderRadius: t.cornerRadius,
191
+ padding: "10px 14px",
192
+ cursor: onOpen || onMarkRead ? "pointer" : "default",
193
+ }}
194
+ >
195
+ <span
196
+ aria-hidden
197
+ style={{
198
+ width: 32,
199
+ height: 32,
200
+ borderRadius: "50%",
201
+ background: `${t.primary}1a`,
202
+ color: t.primary,
203
+ display: "flex",
204
+ alignItems: "center",
205
+ justifyContent: "center",
206
+ flexShrink: 0,
207
+ }}
208
+ >
209
+ <NotificationIcon type={notification.type} />
210
+ </span>
211
+ <div style={{ flex: 1, minWidth: 0 }}>
212
+ <div style={{ fontSize: 14, fontWeight: unread ? 600 : 400, color: t.text }}>
213
+ {NOTIFICATION_COPY[notification.type] ?? "Community update"}
214
+ </div>
215
+ <div style={{ fontSize: 12, color: t.muted }}>{timeAgo(notification.createdAt)}</div>
216
+ </div>
217
+ {unread && (
218
+ <span aria-label="Unread" style={{ width: 8, height: 8, borderRadius: "50%", background: t.primary, flexShrink: 0 }} />
219
+ )}
220
+ </div>
221
+ );
222
+ }