@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,109 @@
1
+ import { useSignupForm } from "../headless/auth/useSignupForm";
2
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
+
4
+ export interface SignupFormProps {
5
+ /** Called after a successful signup. The shell typically redirects here. */
6
+ onSuccess?: (smartLinkPath?: string) => void;
7
+ /** Href for the "Login" link. */
8
+ loginHref: string;
9
+ membershipTierId?: string;
10
+ couponCode?: string;
11
+ }
12
+
13
+ /** Themed account signup form, built on `useSignupForm`. */
14
+ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode }: SignupFormProps) {
15
+ const theme = useForgeTheme();
16
+ const form = useSignupForm({ onSuccess, membershipTierId, couponCode });
17
+
18
+ const card: React.CSSProperties = {
19
+ maxWidth: 420,
20
+ margin: "40px auto 0",
21
+ padding: 24,
22
+ border: `1px solid ${theme.colors.primary}40`,
23
+ borderRadius: theme.cornerRadius,
24
+ color: theme.colors.text,
25
+ };
26
+ const input: React.CSSProperties = {
27
+ width: "100%",
28
+ height: 40,
29
+ padding: "8px 12px",
30
+ borderRadius: theme.cornerRadius,
31
+ border: `1px solid ${theme.colors.primary}40`,
32
+ background: "transparent",
33
+ color: theme.colors.text,
34
+ outline: "none",
35
+ };
36
+ const button: React.CSSProperties = {
37
+ width: "100%",
38
+ padding: "10px 14px",
39
+ borderRadius: theme.cornerRadius,
40
+ background: theme.colors.primary,
41
+ color: theme.colors.background,
42
+ border: "none",
43
+ fontWeight: 600,
44
+ cursor: "pointer",
45
+ };
46
+ const field = (label: string, node: React.ReactNode) => (
47
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
48
+ <label style={{ fontSize: 14 }}>{label}</label>
49
+ {node}
50
+ </div>
51
+ );
52
+
53
+ return (
54
+ <div style={card}>
55
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>Create your account</h1>
56
+ {form.error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{form.error}</p>}
57
+ <form
58
+ onSubmit={(e) => {
59
+ e.preventDefault();
60
+ form.submit();
61
+ }}
62
+ style={{ display: "flex", flexDirection: "column", gap: 16 }}
63
+ >
64
+ <div style={{ display: "flex", gap: 12 }}>
65
+ {field(
66
+ "First name",
67
+ <input value={form.firstName} onChange={(e) => form.setFirstName(e.target.value)} style={input} placeholder="First name" />,
68
+ )}
69
+ {field(
70
+ "Last name",
71
+ <input value={form.lastName} onChange={(e) => form.setLastName(e.target.value)} style={input} placeholder="Last name" />,
72
+ )}
73
+ </div>
74
+ {field(
75
+ "Email",
76
+ <input type="email" value={form.email} onChange={(e) => form.setEmail(e.target.value)} style={input} placeholder="Email" />,
77
+ )}
78
+ {field(
79
+ "Password",
80
+ <input
81
+ type="password"
82
+ value={form.password}
83
+ onChange={(e) => form.setPassword(e.target.value)}
84
+ style={input}
85
+ placeholder="At least 8 characters"
86
+ />,
87
+ )}
88
+ <label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}>
89
+ <input
90
+ type="checkbox"
91
+ checked={form.acceptedTerms}
92
+ onChange={(e) => form.setAcceptedTerms(e.target.checked)}
93
+ style={{ accentColor: theme.colors.primary }}
94
+ />
95
+ I accept the Terms and Privacy Policy
96
+ </label>
97
+ <button type="submit" disabled={form.isSubmitting} style={button}>
98
+ {form.isSubmitting ? "Creating account…" : "Sign up"}
99
+ </button>
100
+ </form>
101
+ <p style={{ marginTop: 16, fontSize: 14, textAlign: "center" }}>
102
+ Already have an account?{" "}
103
+ <a href={loginHref} style={{ color: theme.colors.primary, textDecoration: "underline" }}>
104
+ Login
105
+ </a>
106
+ </p>
107
+ </div>
108
+ );
109
+ }
@@ -0,0 +1,166 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { User, LogIn, LogOut } from "lucide-react";
3
+ import { usePublicAuth } from "../../contexts/PublicAuthContext";
4
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
+
6
+ export interface UserMenuProps {
7
+ /** Custom trigger icon (defaults to a user icon). */
8
+ icon?: React.ReactNode;
9
+ /** Account link (logged in). Default `/i/account`. */
10
+ accountHref?: string;
11
+ /** Login link (logged out). Default `/i/login`. */
12
+ loginHref?: string;
13
+ /** Signup link (logged out). Default `/i/signup`. */
14
+ signupHref?: string;
15
+ /**
16
+ * Which edge the popover anchors to. `right` (default) opens toward the left
17
+ * — correct when the menu sits on the right of a header. Use `left` when the
18
+ * trigger sits near the left edge (e.g. inside a mobile drawer) so the popover
19
+ * doesn't spill off-screen.
20
+ */
21
+ align?: "left" | "right";
22
+ /** Extra class(es) on the trigger. */
23
+ className?: string;
24
+ /** Inline style merged into the trigger. */
25
+ style?: React.CSSProperties;
26
+ }
27
+
28
+ /**
29
+ * Drop-in account menu: an auth-aware user icon that opens a small popover.
30
+ * Logged in → the user's name/email + an Account link + Log out; logged out →
31
+ * Log in / Create account. Built on the Forge `usePublicAuth` context (requires
32
+ * `PublicAuthProvider`, already in the site root). Pass your own `icon` or get
33
+ * the default. Drop it into a header: `<UserMenu />`.
34
+ */
35
+ export function UserMenu({
36
+ icon,
37
+ accountHref = "/i/account",
38
+ loginHref = "/i/login",
39
+ signupHref = "/i/signup",
40
+ align = "right",
41
+ className,
42
+ style,
43
+ }: UserMenuProps) {
44
+ const t = useThemeTokens();
45
+ const { user, logout } = usePublicAuth();
46
+ const [open, setOpen] = useState(false);
47
+ const ref = useRef<HTMLDivElement>(null);
48
+
49
+ useEffect(() => {
50
+ if (!open) return;
51
+ const onDoc = (e: MouseEvent) => {
52
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
53
+ };
54
+ const onKey = (e: KeyboardEvent) => {
55
+ if (e.key === "Escape") setOpen(false);
56
+ };
57
+ document.addEventListener("mousedown", onDoc);
58
+ document.addEventListener("keydown", onKey);
59
+ return () => {
60
+ document.removeEventListener("mousedown", onDoc);
61
+ document.removeEventListener("keydown", onKey);
62
+ };
63
+ }, [open]);
64
+
65
+ const item: React.CSSProperties = {
66
+ color: t.primary,
67
+ background: "transparent",
68
+ border: "none",
69
+ cursor: "pointer",
70
+ textDecoration: "none",
71
+ fontSize: 14,
72
+ textAlign: "left",
73
+ padding: 0,
74
+ display: "inline-flex",
75
+ alignItems: "center",
76
+ gap: 6,
77
+ };
78
+
79
+ return (
80
+ <div ref={ref} className={className} style={{ position: "relative", display: "inline-flex", ...style }}>
81
+ <button
82
+ type="button"
83
+ onClick={() => setOpen((v) => !v)}
84
+ aria-label="Account"
85
+ aria-expanded={open}
86
+ style={{
87
+ background: "transparent",
88
+ border: "none",
89
+ cursor: "pointer",
90
+ color: t.text,
91
+ display: "inline-flex",
92
+ alignItems: "center",
93
+ padding: 4,
94
+ }}
95
+ >
96
+ {icon ?? <User size={20} />}
97
+ </button>
98
+
99
+ {open && (
100
+ <div
101
+ role="menu"
102
+ style={{
103
+ position: "absolute",
104
+ top: "calc(100% + 8px)",
105
+ ...(align === "left" ? { left: 0 } : { right: 0 }),
106
+ minWidth: 220,
107
+ maxWidth: "calc(100vw - 32px)",
108
+ background: t.background,
109
+ color: t.text,
110
+ border: `1px solid ${t.border}`,
111
+ borderRadius: t.cornerRadius,
112
+ boxShadow: "0 10px 30px -12px rgba(0,0,0,0.4)",
113
+ padding: 16,
114
+ zIndex: 60,
115
+ fontFamily: t.fontFamily,
116
+ }}
117
+ >
118
+ {user ? (
119
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
120
+ <div>
121
+ <p style={{ fontWeight: 600, fontSize: 14, margin: 0 }}>
122
+ {user.firstName} {user.lastName}
123
+ </p>
124
+ <p style={{ fontSize: 12, opacity: 0.7, margin: "2px 0 0" }}>{user.email}</p>
125
+ </div>
126
+ <div
127
+ style={{
128
+ paddingTop: 8,
129
+ borderTop: `1px solid ${t.border}`,
130
+ display: "flex",
131
+ flexDirection: "column",
132
+ gap: 10,
133
+ alignItems: "flex-start",
134
+ }}
135
+ >
136
+ <a href={accountHref} role="menuitem" style={item} onClick={() => setOpen(false)}>
137
+ My Account
138
+ </a>
139
+ <button
140
+ type="button"
141
+ role="menuitem"
142
+ style={item}
143
+ onClick={() => {
144
+ setOpen(false);
145
+ void logout();
146
+ }}
147
+ >
148
+ <LogOut size={14} /> Log out
149
+ </button>
150
+ </div>
151
+ </div>
152
+ ) : (
153
+ <div style={{ display: "flex", flexDirection: "column", gap: 10, alignItems: "flex-start" }}>
154
+ <a href={loginHref} role="menuitem" style={item} onClick={() => setOpen(false)}>
155
+ <LogIn size={16} /> Log in
156
+ </a>
157
+ <a href={signupHref} role="menuitem" style={item} onClick={() => setOpen(false)}>
158
+ Create account
159
+ </a>
160
+ </div>
161
+ )}
162
+ </div>
163
+ )}
164
+ </div>
165
+ );
166
+ }
@@ -0,0 +1,172 @@
1
+ import { useState } from "react";
2
+ import type { ChatAttachment } from "../../../data/queries/useChat";
3
+ import { attachmentKind, formatBytes } from "../../headless/chat/attachmentKind";
4
+ import { useForgeTheme } from "../../theme/ForgeThemeProvider";
5
+ import { readableTextOn } from "../../theme/contrast";
6
+
7
+ /** Renders a message's attachments with a preview per file type + download. */
8
+ export function ChatAttachments({ attachments }: { attachments: ChatAttachment[] }) {
9
+ const [lightbox, setLightbox] = useState<string | null>(null);
10
+ if (!attachments?.length) return null;
11
+
12
+ return (
13
+ <div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 6 }}>
14
+ {attachments.map((att, i) => (
15
+ <AttachmentItem key={`${att.url}-${i}`} attachment={att} onOpenImage={() => setLightbox(att.url)} />
16
+ ))}
17
+ {lightbox && <Lightbox url={lightbox} onClose={() => setLightbox(null)} />}
18
+ </div>
19
+ );
20
+ }
21
+
22
+ function AttachmentItem({ attachment, onOpenImage }: { attachment: ChatAttachment; onOpenImage: () => void }) {
23
+ const theme = useForgeTheme();
24
+ const kind = attachmentKind(attachment);
25
+ const border = `1px solid ${theme.colors.text}1a`;
26
+
27
+ if (kind === "image") {
28
+ return (
29
+ <img
30
+ src={attachment.url}
31
+ alt={attachment.fileName}
32
+ onClick={onOpenImage}
33
+ style={{ maxWidth: 240, maxHeight: 240, borderRadius: theme.cornerRadius, cursor: "zoom-in", objectFit: "cover" }}
34
+ />
35
+ );
36
+ }
37
+
38
+ if (kind === "video") {
39
+ return (
40
+ <video
41
+ src={attachment.url}
42
+ controls
43
+ preload="metadata"
44
+ style={{ maxWidth: 280, borderRadius: theme.cornerRadius }}
45
+ />
46
+ );
47
+ }
48
+
49
+ if (kind === "audio") {
50
+ return (
51
+ <div style={{ display: "flex", flexDirection: "column", gap: 4, maxWidth: 280 }}>
52
+ <span style={{ fontSize: 12, opacity: 0.75 }}>{attachment.fileName}</span>
53
+ <audio src={attachment.url} controls preload="metadata" style={{ width: "100%" }} />
54
+ </div>
55
+ );
56
+ }
57
+
58
+ if (kind === "pdf") {
59
+ return <PdfAttachment attachment={attachment} />;
60
+ }
61
+
62
+ return <FileCard attachment={attachment} border={border} />;
63
+ }
64
+
65
+ function PdfAttachment({ attachment }: { attachment: ChatAttachment }) {
66
+ const theme = useForgeTheme();
67
+ const [open, setOpen] = useState(false);
68
+ const border = `1px solid ${theme.colors.text}1a`;
69
+ return (
70
+ <div style={{ display: "flex", flexDirection: "column", gap: 6, maxWidth: 320 }}>
71
+ <FileCard
72
+ attachment={attachment}
73
+ border={border}
74
+ extra={
75
+ <button
76
+ onClick={() => setOpen((v) => !v)}
77
+ style={{
78
+ background: "none",
79
+ border,
80
+ borderRadius: 6,
81
+ padding: "2px 8px",
82
+ cursor: "pointer",
83
+ color: theme.colors.text,
84
+ fontSize: 12,
85
+ }}
86
+ >
87
+ {open ? "Hide" : "Preview"}
88
+ </button>
89
+ }
90
+ />
91
+ {open && (
92
+ <iframe
93
+ title={attachment.fileName}
94
+ src={attachment.url}
95
+ style={{ width: "100%", height: 360, border, borderRadius: theme.cornerRadius }}
96
+ />
97
+ )}
98
+ </div>
99
+ );
100
+ }
101
+
102
+ function FileCard({
103
+ attachment,
104
+ border,
105
+ extra,
106
+ }: {
107
+ attachment: ChatAttachment;
108
+ border: string;
109
+ extra?: React.ReactNode;
110
+ }) {
111
+ const theme = useForgeTheme();
112
+ return (
113
+ <div
114
+ style={{
115
+ display: "flex",
116
+ alignItems: "center",
117
+ gap: 10,
118
+ padding: "8px 10px",
119
+ border,
120
+ borderRadius: theme.cornerRadius,
121
+ maxWidth: 320,
122
+ }}
123
+ >
124
+ <span style={{ fontSize: 20 }} aria-hidden>
125
+ 📄
126
+ </span>
127
+ <div style={{ minWidth: 0, flex: 1 }}>
128
+ <div style={{ fontSize: 13, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
129
+ {attachment.fileName}
130
+ </div>
131
+ <div style={{ fontSize: 11, opacity: 0.6 }}>{formatBytes(attachment.fileSize)}</div>
132
+ </div>
133
+ {extra}
134
+ <a
135
+ href={attachment.url}
136
+ target="_blank"
137
+ rel="noopener noreferrer"
138
+ download
139
+ style={{
140
+ background: theme.colors.primary,
141
+ color: readableTextOn(theme.colors.primary),
142
+ borderRadius: 6,
143
+ padding: "2px 8px",
144
+ fontSize: 12,
145
+ textDecoration: "none",
146
+ }}
147
+ >
148
+ Download
149
+ </a>
150
+ </div>
151
+ );
152
+ }
153
+
154
+ function Lightbox({ url, onClose }: { url: string; onClose: () => void }) {
155
+ return (
156
+ <div
157
+ onClick={onClose}
158
+ style={{
159
+ position: "fixed",
160
+ inset: 0,
161
+ background: "rgba(0,0,0,0.8)",
162
+ display: "flex",
163
+ alignItems: "center",
164
+ justifyContent: "center",
165
+ zIndex: 1000,
166
+ cursor: "zoom-out",
167
+ }}
168
+ >
169
+ <img src={url} alt="" style={{ maxWidth: "90vw", maxHeight: "90vh", objectFit: "contain" }} />
170
+ </div>
171
+ );
172
+ }
@@ -0,0 +1,153 @@
1
+ import { useRef, useState } from "react";
2
+ import type { ChatAttachment } from "../../../data/queries/useChat";
3
+ import { useChatAttachmentUpload } from "../../headless/chat/useChatAttachmentUpload";
4
+ import { formatBytes } from "../../headless/chat/attachmentKind";
5
+ import { useForgeTheme } from "../../theme/ForgeThemeProvider";
6
+ import { readableTextOn } from "../../theme/contrast";
7
+
8
+ export interface ChatComposerProps {
9
+ onSend: (content: string, attachments: ChatAttachment[]) => void;
10
+ onTyping?: () => void;
11
+ border: string;
12
+ placeholder?: string;
13
+ }
14
+
15
+ /** Message composer shared by the main panel and the thread panel. */
16
+ export function ChatComposer({ onSend, onTyping, border, placeholder = "Message…" }: ChatComposerProps) {
17
+ const theme = useForgeTheme();
18
+ const [draft, setDraft] = useState("");
19
+ const upload = useChatAttachmentUpload();
20
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
21
+ const [sending, setSending] = useState(false);
22
+
23
+ const send = async () => {
24
+ if (sending) return;
25
+ if (!draft.trim() && !upload.hasPending) return;
26
+ setSending(true);
27
+ try {
28
+ const attachments = upload.hasPending ? await upload.uploadAll() : [];
29
+ onSend(draft.trim(), attachments);
30
+ setDraft("");
31
+ upload.clear();
32
+ } catch {
33
+ // Upload failed — the error surfaces via `upload.error`. Keep the draft
34
+ // and staged files so the member can retry.
35
+ } finally {
36
+ setSending(false);
37
+ }
38
+ };
39
+
40
+ return (
41
+ <div style={{ borderTop: border }}>
42
+ {upload.error && (
43
+ <div style={{ padding: "8px 12px 0", fontSize: 12, color: "#dc2626" }}>{upload.error}</div>
44
+ )}
45
+ {upload.pending.length > 0 && (
46
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8, padding: "8px 12px 0" }}>
47
+ {upload.pending.map((p) => (
48
+ <div
49
+ key={p.id}
50
+ style={{
51
+ display: "flex",
52
+ alignItems: "center",
53
+ gap: 6,
54
+ padding: "4px 8px",
55
+ border,
56
+ borderRadius: theme.cornerRadius,
57
+ maxWidth: 200,
58
+ }}
59
+ >
60
+ {p.kind === "image" && p.previewUrl ? (
61
+ <img src={p.previewUrl} alt="" style={{ width: 28, height: 28, objectFit: "cover", borderRadius: 4 }} />
62
+ ) : (
63
+ <span aria-hidden>📎</span>
64
+ )}
65
+ <div style={{ minWidth: 0, flex: 1 }}>
66
+ <div style={{ fontSize: 12, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
67
+ {p.file.name}
68
+ </div>
69
+ <div style={{ fontSize: 10, opacity: 0.6 }}>
70
+ {p.status === "uploading" ? `${p.progress}%` : formatBytes(p.file.size)}
71
+ </div>
72
+ </div>
73
+ <button
74
+ onClick={() => upload.removeFile(p.id)}
75
+ style={{ background: "none", border: "none", cursor: "pointer", color: theme.colors.text }}
76
+ aria-label="Remove attachment"
77
+ >
78
+ ×
79
+ </button>
80
+ </div>
81
+ ))}
82
+ </div>
83
+ )}
84
+
85
+ <div style={{ display: "flex", gap: 8, padding: 12 }}>
86
+ <input
87
+ ref={fileInputRef}
88
+ type="file"
89
+ multiple
90
+ hidden
91
+ onChange={(e) => {
92
+ if (e.target.files) upload.addFiles(e.target.files);
93
+ e.target.value = "";
94
+ }}
95
+ />
96
+ <button
97
+ onClick={() => fileInputRef.current?.click()}
98
+ disabled={!upload.canAddMore}
99
+ title="Attach files"
100
+ style={{
101
+ background: "none",
102
+ border,
103
+ borderRadius: theme.cornerRadius,
104
+ padding: "0 12px",
105
+ cursor: upload.canAddMore ? "pointer" : "not-allowed",
106
+ color: theme.colors.text,
107
+ fontSize: 18,
108
+ }}
109
+ >
110
+ 📎
111
+ </button>
112
+ <input
113
+ value={draft}
114
+ onChange={(e) => {
115
+ setDraft(e.target.value);
116
+ onTyping?.();
117
+ }}
118
+ onKeyDown={(e) => {
119
+ if (e.key === "Enter" && !e.shiftKey) {
120
+ e.preventDefault();
121
+ send();
122
+ }
123
+ }}
124
+ placeholder={placeholder}
125
+ style={{
126
+ flex: 1,
127
+ padding: "10px 12px",
128
+ borderRadius: theme.cornerRadius,
129
+ border,
130
+ background: theme.colors.background,
131
+ color: theme.colors.text,
132
+ }}
133
+ />
134
+ <button
135
+ onClick={send}
136
+ disabled={sending}
137
+ style={{
138
+ background: theme.colors.primary,
139
+ color: readableTextOn(theme.colors.primary),
140
+ border: "none",
141
+ borderRadius: theme.cornerRadius,
142
+ padding: "0 18px",
143
+ fontWeight: 600,
144
+ cursor: sending ? "wait" : "pointer",
145
+ opacity: sending ? 0.7 : 1,
146
+ }}
147
+ >
148
+ {sending ? "…" : "Send"}
149
+ </button>
150
+ </div>
151
+ </div>
152
+ );
153
+ }
@@ -0,0 +1,94 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { ChatMessage } from "../../../data/queries/useChat";
3
+ import type { Socket } from "socket.io-client";
4
+ import { useMessageThread } from "../../headless/chat/useMessageThread";
5
+ import { useForgeTheme } from "../../theme/ForgeThemeProvider";
6
+ import { MessageBubble } from "./MessageBubble";
7
+ import { ChatComposer } from "./ChatComposer";
8
+
9
+ export interface ChatThreadPanelProps {
10
+ parentMessage: ChatMessage;
11
+ socket: Socket | null;
12
+ currentAccountId?: string;
13
+ onClose: () => void;
14
+ onMarkAsRead?: (messageIds: string[]) => void;
15
+ border: string;
16
+ }
17
+
18
+ /**
19
+ * One-level thread panel — a side panel on desktop, full-screen overlay on
20
+ * mobile. Renders the parent, its replies, and a reply composer.
21
+ */
22
+ export function ChatThreadPanel({
23
+ parentMessage,
24
+ socket,
25
+ currentAccountId,
26
+ onClose,
27
+ onMarkAsRead,
28
+ border,
29
+ }: ChatThreadPanelProps) {
30
+ const theme = useForgeTheme();
31
+ const thread = useMessageThread({ parentMessage, socket });
32
+
33
+ // Thread replies count toward the channel's unread total but never appear in
34
+ // the main timeline (which is where reads are normally marked). Mark the
35
+ // parent + replies read so opening the thread clears its unread. Guard with a
36
+ // ref of already-marked ids: the markAsRead callback identity changes as the
37
+ // mutation runs, so re-running without this guard would loop.
38
+ const markedRef = useRef<Set<string>>(new Set());
39
+ useEffect(() => {
40
+ if (!onMarkAsRead) return;
41
+ const ids = [parentMessage, ...thread.replies]
42
+ .filter((m) => m.senderAccountId !== currentAccountId && !markedRef.current.has(m.id))
43
+ .map((m) => m.id);
44
+ if (ids.length === 0) return;
45
+ ids.forEach((id) => markedRef.current.add(id));
46
+ onMarkAsRead(ids);
47
+ }, [parentMessage, thread.replies, currentAccountId, onMarkAsRead]);
48
+
49
+ return (
50
+ <section
51
+ className="fixed inset-0 z-50 md:static md:z-auto md:w-[360px] flex flex-col"
52
+ style={{ background: theme.colors.background, borderLeft: border }}
53
+ >
54
+ <header style={{ padding: "12px 16px", borderBottom: border, display: "flex", alignItems: "center", gap: 12 }}>
55
+ <strong style={{ flex: 1 }}>Thread</strong>
56
+ <button
57
+ onClick={onClose}
58
+ style={{ background: "none", border: "none", cursor: "pointer", color: theme.colors.text, fontSize: 18 }}
59
+ aria-label="Close thread"
60
+ >
61
+ ×
62
+ </button>
63
+ </header>
64
+
65
+ <div style={{ padding: 16, borderBottom: border }}>
66
+ <MessageBubble message={parentMessage} mine={parentMessage.senderAccountId === currentAccountId} showThreadControls={false} />
67
+ </div>
68
+
69
+ <div style={{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 10 }}>
70
+ {thread.hasMore && (
71
+ <button
72
+ onClick={() => thread.fetchNextPage()}
73
+ style={{ alignSelf: "center", background: "none", border, borderRadius: 8, padding: "4px 10px", cursor: "pointer", color: theme.colors.text }}
74
+ >
75
+ Load earlier replies
76
+ </button>
77
+ )}
78
+ {thread.replies.map((m) => (
79
+ <MessageBubble key={m.id} message={m} mine={m.senderAccountId === currentAccountId} showThreadControls={false} />
80
+ ))}
81
+ {!thread.isLoading && thread.replies.length === 0 && (
82
+ <div style={{ opacity: 0.5, fontSize: 13, textAlign: "center" }}>No replies yet. Start the thread.</div>
83
+ )}
84
+ </div>
85
+
86
+ <ChatComposer
87
+ onSend={(content, attachments) => thread.sendReply(content, attachments)}
88
+ onTyping={thread.handleTyping}
89
+ border={border}
90
+ placeholder="Reply…"
91
+ />
92
+ </section>
93
+ );
94
+ }