@tribe-nest/forge 3.59.0 → 3.61.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 (266) hide show
  1. package/package.json +2 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CartContext.tsx +34 -1
  4. package/src/contexts/CommunityContext.tsx +192 -0
  5. package/src/contexts/PublicAuthContext.tsx +15 -3
  6. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  7. package/src/data/queries/useAccount.ts +33 -2
  8. package/src/data/queries/useAnalytics.ts +6 -6
  9. package/src/data/queries/useCallChat.ts +2 -2
  10. package/src/data/queries/useChat.ts +52 -40
  11. package/src/data/queries/useCheckouts.ts +2 -1
  12. package/src/data/queries/useCoachingAvailability.ts +10 -1
  13. package/src/data/queries/useCommunities.ts +345 -0
  14. package/src/data/queries/useCommunity.ts +450 -102
  15. package/src/data/queries/useCommunityAgent.ts +247 -0
  16. package/src/data/queries/useCommunityCalendar.ts +150 -0
  17. package/src/data/queries/useCommunityClassroom.ts +75 -0
  18. package/src/data/queries/useCommunityMembers.ts +108 -0
  19. package/src/data/queries/useCommunityModeration.ts +327 -0
  20. package/src/data/queries/useCommunityWiki.ts +137 -0
  21. package/src/data/queries/useCourses.ts +2 -0
  22. package/src/data/queries/useEmailSignIn.ts +79 -0
  23. package/src/data/queries/useEvents.ts +6 -1
  24. package/src/data/queries/useForms.ts +5 -1
  25. package/src/data/queries/useLeadMagnet.ts +6 -2
  26. package/src/data/queries/useMembership.ts +32 -7
  27. package/src/data/queries/useMembershipCalls.ts +116 -7
  28. package/src/data/queries/useMyBookings.ts +24 -1
  29. package/src/data/queries/useMyTickets.ts +24 -1
  30. package/src/data/queries/useNotifications.ts +6 -2
  31. package/src/data/queries/useOrders.ts +2 -1
  32. package/src/data/queries/usePWA.ts +111 -35
  33. package/src/data/queries/usePostCollections.ts +28 -16
  34. package/src/data/queries/usePosts.ts +16 -14
  35. package/src/data/queries/useSiteApp.ts +130 -0
  36. package/src/data/queries/useSubscriptions.ts +28 -9
  37. package/src/i18n/de.json +891 -2
  38. package/src/i18n/en.json +891 -2
  39. package/src/index.ts +36 -7
  40. package/src/runtime/pages.spec.ts +36 -0
  41. package/src/runtime/pages.ts +52 -27
  42. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  43. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  44. package/src/types/models.ts +11 -0
  45. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  46. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  47. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  48. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  49. package/src/ui/analytics/useMetaEvents.ts +78 -0
  50. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  51. package/src/ui/format/membershipGate.ts +19 -15
  52. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  53. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  54. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  55. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  56. package/src/ui/headless/_tests/communityHarness.tsx +212 -0
  57. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  58. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  59. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  60. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  61. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  62. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  63. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  64. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  65. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  66. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
  67. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
  68. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  69. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
  70. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  71. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  72. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  73. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  74. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  75. package/src/ui/headless/auth/useSignupForm.ts +30 -3
  76. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  77. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  78. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  79. package/src/ui/headless/communities/index.ts +96 -0
  80. package/src/ui/headless/communities/paths.ts +36 -0
  81. package/src/ui/headless/communities/useCommunity.ts +140 -0
  82. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  83. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  84. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  85. package/src/ui/headless/communities/useCommunityHome.ts +91 -0
  86. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  87. package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
  88. package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
  89. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  90. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  91. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  92. package/src/ui/headless/community/communityPoll.ts +297 -0
  93. package/src/ui/headless/community/countries.ts +123 -0
  94. package/src/ui/headless/community/index.ts +81 -1
  95. package/src/ui/headless/community/linkPreview.ts +71 -0
  96. package/src/ui/headless/community/richLexical.ts +27 -2
  97. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  98. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  99. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  100. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  101. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  102. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  103. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  104. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  105. package/src/ui/headless/downloads/index.ts +8 -0
  106. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  107. package/src/ui/headless/index.ts +21 -4
  108. package/src/ui/headless/memberHome/index.ts +11 -1
  109. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  110. package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
  111. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  112. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  113. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  114. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  115. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  116. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  117. package/src/ui/headless/portal/index.ts +57 -0
  118. package/src/ui/headless/portal/usePortal.ts +274 -0
  119. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  120. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  121. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  122. package/src/ui/headless/posts/upsell.ts +54 -8
  123. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  124. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  125. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  126. package/src/ui/headless/posts/usePostItem.ts +6 -5
  127. package/src/ui/headless/siteApp/appBar.ts +102 -0
  128. package/src/ui/headless/siteApp/appHome.ts +344 -0
  129. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  130. package/src/ui/headless/siteApp/index.ts +58 -0
  131. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  132. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  133. package/src/ui/headless/work/index.ts +2 -0
  134. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  135. package/src/ui/index.ts +28 -12
  136. package/src/ui/styled/AccountDashboard.tsx +754 -602
  137. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  138. package/src/ui/styled/ChatRoom.tsx +126 -16
  139. package/src/ui/styled/Checkout.tsx +8 -0
  140. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  141. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  142. package/src/ui/styled/CoachingDetail.tsx +2 -0
  143. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  144. package/src/ui/styled/CourseDetail.tsx +2 -0
  145. package/src/ui/styled/DownloadPage.tsx +263 -0
  146. package/src/ui/styled/EventConfirmation.tsx +10 -0
  147. package/src/ui/styled/EventDetail.tsx +10 -0
  148. package/src/ui/styled/FilmLibrary.tsx +22 -9
  149. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  150. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  151. package/src/ui/styled/MembershipTiers.tsx +49 -11
  152. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  153. package/src/ui/styled/Paywall.tsx +1 -1
  154. package/src/ui/styled/ProductDetail.tsx +12 -0
  155. package/src/ui/styled/PwaRegistration.tsx +5 -1
  156. package/src/ui/styled/SignupForm.tsx +18 -3
  157. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
  158. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  159. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  160. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  161. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  162. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  163. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  164. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  165. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  166. package/src/ui/styled/app/AppBar.tsx +298 -0
  167. package/src/ui/styled/app/AppHome.tsx +1139 -0
  168. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  169. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  170. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  171. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  172. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  173. package/src/ui/styled/app/index.ts +7 -0
  174. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  175. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  176. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  177. package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
  178. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  179. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  180. package/src/ui/styled/communities/CommunityHome.tsx +298 -0
  181. package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
  182. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
  183. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  184. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  185. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  186. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  187. package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
  188. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  189. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  190. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  191. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  192. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  193. package/src/ui/styled/communities/index.ts +19 -0
  194. package/src/ui/styled/communities/shared.tsx +177 -0
  195. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  196. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  197. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  198. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  199. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  200. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  201. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  202. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  203. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  204. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  205. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  206. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  207. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  208. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  209. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  210. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  211. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  212. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  213. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  214. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  215. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  216. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  217. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  218. package/src/ui/styled/community/index.ts +37 -2
  219. package/src/ui/styled/forge-utilities.css +253 -0
  220. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  221. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  222. package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
  223. package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
  224. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  225. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  226. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  227. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  228. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  229. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  230. package/src/ui/styled/members/index.ts +8 -0
  231. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  232. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  233. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  234. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  235. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  236. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  237. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  238. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  239. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  240. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  241. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  242. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  243. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  244. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  245. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  246. package/src/ui/styled/portal/index.ts +13 -0
  247. package/src/ui/styled/portal/portalNav.ts +260 -0
  248. package/src/ui/styled/portal/shared.tsx +206 -0
  249. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  250. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  251. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  252. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  253. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  254. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  255. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  256. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  257. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  258. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  259. package/src/ui/styled/work/index.ts +1 -1
  260. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  261. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  262. package/src/utils/landing.ts +19 -6
  263. package/src/utils/metaEvents.ts +302 -0
  264. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  265. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  266. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
@@ -0,0 +1,424 @@
1
+ import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
2
+ import { Download, Search, X } from "lucide-react";
3
+
4
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
5
+ import { useMeasuredWidth } from "../../media/hooks/useMeasuredWidth";
6
+ import { useMemberCallTranscript, type MemberCallTranscriptSegment } from "../../../data/queries/useMembershipCalls";
7
+ import { Loading } from "../Loading";
8
+ import { activeSegmentIndex, downloadTranscript, formatOffset } from "./callTranscriptText";
9
+
10
+ /** How long after the member scrolls the transcript themselves it stops following playback. */
11
+ const FOLLOW_PAUSE_MS = 4_000;
12
+ /** Below this width the video sits above the transcript instead of beside it. */
13
+ const STACK_BELOW_PX = 860;
14
+
15
+ /** `text` with every case-insensitive match of `term` highlighted. */
16
+ function highlight(text: string, term: string, color: string): ReactNode {
17
+ if (!term) return text;
18
+ const lower = text.toLowerCase();
19
+ const needle = term.toLowerCase();
20
+ const out: ReactNode[] = [];
21
+ let from = 0;
22
+ for (let at = lower.indexOf(needle); at !== -1; at = lower.indexOf(needle, from)) {
23
+ out.push(
24
+ text.slice(from, at),
25
+ <mark key={at} style={{ background: color, color: "inherit", borderRadius: 2 }}>
26
+ {text.slice(at, at + needle.length)}
27
+ </mark>,
28
+ );
29
+ from = at + needle.length;
30
+ }
31
+ out.push(text.slice(from));
32
+ return out;
33
+ }
34
+
35
+ /**
36
+ * A recorded call's transcript beside its video, full-screen.
37
+ *
38
+ * A viewer rather than a route: members' calls render on hosted `/i/` pages,
39
+ * on code-built sites and in the client app, and an overlay works on all of
40
+ * them without a new page on each. The same layout as the creator's transcript
41
+ * page: the video plays on one side, the line being spoken highlights and
42
+ * follows along, clicking a line seeks, and there is a search and a Summary
43
+ * tab.
44
+ *
45
+ * The video address is signed for sixty seconds on purpose. When playback
46
+ * fails after that, the viewer fetches a fresh one and resumes where the member
47
+ * was.
48
+ */
49
+ export function CallTranscriptViewer({ recordingId, onClose }: { recordingId: string; onClose: () => void }) {
50
+ const t = useThemeTokens();
51
+ const query = useMemberCallTranscript(recordingId);
52
+
53
+ useEffect(() => {
54
+ const onKey = (event: KeyboardEvent) => event.key === "Escape" && onClose();
55
+ window.addEventListener("keydown", onKey);
56
+ return () => window.removeEventListener("keydown", onKey);
57
+ }, [onClose]);
58
+
59
+ const shell: CSSProperties = {
60
+ position: "fixed",
61
+ inset: 0,
62
+ zIndex: 1001,
63
+ display: "flex",
64
+ flexDirection: "column",
65
+ background: t.background,
66
+ color: t.text,
67
+ fontFamily: t.fontFamily,
68
+ };
69
+
70
+ if (query.isLoading) {
71
+ return (
72
+ <div role="dialog" aria-modal="true" aria-label="Transcript" style={shell}>
73
+ <Loading />
74
+ </div>
75
+ );
76
+ }
77
+ if (query.error || !query.data) {
78
+ return (
79
+ <div role="dialog" aria-modal="true" aria-label="Transcript" style={{ ...shell, padding: 24, gap: 12 }}>
80
+ <p style={{ margin: 0, color: t.muted }}>We could not open this transcript just now.</p>
81
+ <button type="button" onClick={onClose} style={buttonStyle(t)}>
82
+ Close
83
+ </button>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ return <ViewerBody detail={query.data} refetch={query.refetch} onClose={onClose} shell={shell} />;
89
+ }
90
+
91
+ type Tokens = ReturnType<typeof useThemeTokens>;
92
+
93
+ function buttonStyle(t: Tokens): CSSProperties {
94
+ return {
95
+ display: "inline-flex",
96
+ alignItems: "center",
97
+ gap: 6,
98
+ padding: "8px 14px",
99
+ borderRadius: t.cornerRadius,
100
+ border: `1px solid ${t.border}`,
101
+ background: "transparent",
102
+ color: t.text,
103
+ fontFamily: t.fontFamily,
104
+ fontSize: 14,
105
+ fontWeight: 600,
106
+ cursor: "pointer",
107
+ };
108
+ }
109
+
110
+ function ViewerBody({
111
+ detail,
112
+ refetch,
113
+ onClose,
114
+ shell,
115
+ }: {
116
+ detail: NonNullable<ReturnType<typeof useMemberCallTranscript>["data"]>;
117
+ refetch: ReturnType<typeof useMemberCallTranscript>["refetch"];
118
+ onClose: () => void;
119
+ shell: CSSProperties;
120
+ }) {
121
+ const t = useThemeTokens();
122
+ const { segments, summary } = detail.transcript;
123
+ const [bodyRef, width] = useMeasuredWidth<HTMLDivElement>();
124
+ const stacked = width > 0 && width < STACK_BELOW_PX;
125
+ const videoRef = useRef<HTMLVideoElement>(null);
126
+ const listRef = useRef<HTMLDivElement>(null);
127
+ const [watchUrl, setWatchUrl] = useState(detail.watchUrl);
128
+ const [currentMs, setCurrentMs] = useState(0);
129
+ const [term, setTerm] = useState("");
130
+ const [tab, setTab] = useState<"summary" | "transcript">(summary ? "summary" : "transcript");
131
+ const lastManualScroll = useRef(0);
132
+ const resume = useRef<{ atSec: number; play: boolean } | null>(null);
133
+ const lastRefresh = useRef(0);
134
+
135
+ const active = activeSegmentIndex(segments, currentMs);
136
+ const needle = term.trim();
137
+ const matches = useMemo(() => {
138
+ const lower = needle.toLowerCase();
139
+ if (!lower) return null;
140
+ return segments
141
+ .map((segment, index) => ({ segment, index }))
142
+ .filter(({ segment }) => `${segment.name} ${segment.text}`.toLowerCase().includes(lower));
143
+ }, [segments, needle]);
144
+ const shown = matches ?? segments.map((segment, index) => ({ segment, index }));
145
+
146
+ useEffect(() => {
147
+ if (tab !== "transcript" || matches || active < 0) return;
148
+ if (Date.now() - lastManualScroll.current < FOLLOW_PAUSE_MS) return;
149
+ listRef.current
150
+ ?.querySelector<HTMLElement>(`[data-index="${active}"]`)
151
+ ?.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
152
+ }, [active, matches, tab]);
153
+
154
+ const recover = async () => {
155
+ const video = videoRef.current;
156
+ if (!video || Date.now() - lastRefresh.current < 5_000) return;
157
+ lastRefresh.current = Date.now();
158
+ resume.current = { atSec: video.currentTime, play: !video.paused || video.seeking };
159
+ const fresh = await refetch();
160
+ if (fresh.data?.watchUrl) setWatchUrl(fresh.data.watchUrl);
161
+ };
162
+
163
+ const seek = (segment: MemberCallTranscriptSegment) => {
164
+ const video = videoRef.current;
165
+ if (!video) return;
166
+ video.currentTime = segment.startMs / 1000;
167
+ setCurrentMs(segment.startMs);
168
+ void video.play()?.catch(() => undefined);
169
+ };
170
+
171
+ const tabStyle = (selected: boolean): CSSProperties => ({
172
+ padding: "6px 12px",
173
+ border: "none",
174
+ borderBottom: `2px solid ${selected ? t.primary : "transparent"}`,
175
+ background: "transparent",
176
+ color: selected ? t.text : t.muted,
177
+ fontFamily: t.fontFamily,
178
+ fontSize: 14,
179
+ fontWeight: 600,
180
+ cursor: "pointer",
181
+ });
182
+
183
+ return (
184
+ <div role="dialog" aria-modal="true" aria-label={`Transcript: ${detail.title}`} style={shell}>
185
+ <header
186
+ style={{
187
+ display: "flex",
188
+ alignItems: "center",
189
+ gap: 12,
190
+ padding: "12px 16px",
191
+ borderBottom: `1px solid ${t.border}`,
192
+ }}
193
+ >
194
+ <div style={{ minWidth: 0, flex: 1 }}>
195
+ <h2
196
+ style={{
197
+ margin: 0,
198
+ fontSize: 17,
199
+ fontWeight: 700,
200
+ whiteSpace: "nowrap",
201
+ overflow: "hidden",
202
+ textOverflow: "ellipsis",
203
+ }}
204
+ >
205
+ {detail.title}
206
+ </h2>
207
+ <span style={{ fontSize: 13, color: t.muted }}>
208
+ {new Date(detail.startsAt).toLocaleString("en-US", {
209
+ dateStyle: "long",
210
+ timeStyle: "short",
211
+ timeZone: detail.timezone || undefined,
212
+ })}
213
+ </span>
214
+ </div>
215
+ <button type="button" onClick={() => downloadTranscript(detail)} style={buttonStyle(t)}>
216
+ <Download size={15} />
217
+ Download
218
+ </button>
219
+ <button
220
+ type="button"
221
+ onClick={onClose}
222
+ aria-label="Close"
223
+ style={{ background: "transparent", border: "none", cursor: "pointer", color: t.muted, padding: 4 }}
224
+ >
225
+ <X size={20} />
226
+ </button>
227
+ </header>
228
+
229
+ <div
230
+ ref={bodyRef}
231
+ style={{
232
+ flex: 1,
233
+ minHeight: 0,
234
+ display: "flex",
235
+ flexDirection: stacked ? "column" : "row",
236
+ gap: 16,
237
+ padding: 16,
238
+ overflow: stacked ? "auto" : "hidden",
239
+ }}
240
+ >
241
+ <div style={{ flex: stacked ? "none" : 3, minWidth: 0 }}>
242
+ {watchUrl ? (
243
+ <video
244
+ ref={videoRef}
245
+ src={watchUrl}
246
+ controls
247
+ preload="metadata"
248
+ data-testid="member-transcript-video"
249
+ style={{ width: "100%", aspectRatio: "16 / 9", background: "#000", borderRadius: t.cornerRadius }}
250
+ onTimeUpdate={(event) => setCurrentMs(event.currentTarget.currentTime * 1000)}
251
+ onError={() => void recover()}
252
+ onLoadedMetadata={(event) => {
253
+ const pending = resume.current;
254
+ if (!pending) return;
255
+ resume.current = null;
256
+ event.currentTarget.currentTime = pending.atSec;
257
+ if (pending.play) void event.currentTarget.play()?.catch(() => undefined);
258
+ }}
259
+ />
260
+ ) : (
261
+ <div
262
+ style={{
263
+ width: "100%",
264
+ aspectRatio: "16 / 9",
265
+ display: "flex",
266
+ alignItems: "center",
267
+ justifyContent: "center",
268
+ border: `1px solid ${t.border}`,
269
+ borderRadius: t.cornerRadius,
270
+ color: t.muted,
271
+ fontSize: 14,
272
+ }}
273
+ >
274
+ The video is not available.
275
+ </div>
276
+ )}
277
+ </div>
278
+
279
+ <div
280
+ style={{
281
+ flex: stacked ? "none" : 2,
282
+ minWidth: 0,
283
+ minHeight: 0,
284
+ display: "flex",
285
+ flexDirection: "column",
286
+ gap: 10,
287
+ }}
288
+ >
289
+ <div role="tablist" style={{ display: "flex", gap: 4, borderBottom: `1px solid ${t.border}` }}>
290
+ <button
291
+ type="button"
292
+ role="tab"
293
+ aria-selected={tab === "summary"}
294
+ onClick={() => setTab("summary")}
295
+ style={tabStyle(tab === "summary")}
296
+ >
297
+ Summary
298
+ </button>
299
+ <button
300
+ type="button"
301
+ role="tab"
302
+ aria-selected={tab === "transcript"}
303
+ onClick={() => setTab("transcript")}
304
+ style={tabStyle(tab === "transcript")}
305
+ >
306
+ Transcript
307
+ </button>
308
+ </div>
309
+
310
+ {tab === "summary" ? (
311
+ <div
312
+ style={{
313
+ display: "flex",
314
+ flexDirection: "column",
315
+ gap: 12,
316
+ fontSize: 14,
317
+ lineHeight: 1.5,
318
+ overflowY: "auto",
319
+ }}
320
+ >
321
+ {summary ? (
322
+ <>
323
+ <p style={{ margin: 0 }}>{summary.summary}</p>
324
+ {[
325
+ ["Decisions", summary.decisions],
326
+ ["Action items", summary.actionItems],
327
+ ].map(
328
+ ([label, items]) =>
329
+ (items as string[]).length > 0 && (
330
+ <div key={label as string}>
331
+ <span style={{ fontWeight: 600 }}>{label as string}</span>
332
+ <ul style={{ margin: "4px 0 0", paddingLeft: 20 }}>
333
+ {(items as string[]).map((item, i) => (
334
+ <li key={i}>{item}</li>
335
+ ))}
336
+ </ul>
337
+ </div>
338
+ ),
339
+ )}
340
+ </>
341
+ ) : (
342
+ <p style={{ margin: 0, color: t.muted }}>There is no summary for this call.</p>
343
+ )}
344
+ </div>
345
+ ) : (
346
+ <>
347
+ <label style={{ position: "relative", display: "block" }}>
348
+ <Search size={15} style={{ position: "absolute", left: 10, top: 10, color: t.muted }} />
349
+ <input
350
+ value={term}
351
+ onChange={(event) => setTerm(event.target.value)}
352
+ placeholder="Search the transcript"
353
+ aria-label="Search the transcript"
354
+ style={{
355
+ width: "100%",
356
+ boxSizing: "border-box",
357
+ padding: "8px 10px 8px 32px",
358
+ borderRadius: t.cornerRadius,
359
+ border: `1px solid ${t.border}`,
360
+ background: t.surface,
361
+ color: t.text,
362
+ fontFamily: t.fontFamily,
363
+ fontSize: 14,
364
+ }}
365
+ />
366
+ </label>
367
+ {matches && (
368
+ <span role="status" style={{ fontSize: 12, color: t.muted }}>
369
+ {matches.length === 1 ? "1 match" : `${matches.length} matches`}
370
+ </span>
371
+ )}
372
+ <div
373
+ ref={listRef}
374
+ data-testid="member-transcript-lines"
375
+ onWheel={() => (lastManualScroll.current = Date.now())}
376
+ onTouchMove={() => (lastManualScroll.current = Date.now())}
377
+ style={{
378
+ flex: 1,
379
+ minHeight: stacked ? 320 : 0,
380
+ maxHeight: stacked ? 480 : undefined,
381
+ overflowY: "auto",
382
+ border: `1px solid ${t.border}`,
383
+ borderRadius: t.cornerRadius,
384
+ }}
385
+ >
386
+ {shown.map(({ segment, index }) => (
387
+ <button
388
+ type="button"
389
+ key={index}
390
+ data-index={index}
391
+ aria-current={index === active ? "true" : undefined}
392
+ onClick={() => seek(segment)}
393
+ style={{
394
+ display: "flex",
395
+ gap: 10,
396
+ width: "100%",
397
+ padding: "8px 10px",
398
+ border: "none",
399
+ textAlign: "left",
400
+ background: index === active ? t.surface : "transparent",
401
+ color: t.text,
402
+ fontFamily: t.fontFamily,
403
+ fontSize: 14,
404
+ lineHeight: 1.5,
405
+ cursor: "pointer",
406
+ }}
407
+ >
408
+ <span style={{ width: 56, flexShrink: 0, fontFamily: "monospace", fontSize: 12, color: t.primary }}>
409
+ {formatOffset(segment.startMs)}
410
+ </span>
411
+ <span style={{ minWidth: 0 }}>
412
+ <span style={{ fontWeight: 600 }}>{highlight(segment.name, needle, t.border)}</span>{" "}
413
+ {highlight(segment.text, needle, t.border)}
414
+ </span>
415
+ </button>
416
+ ))}
417
+ </div>
418
+ </>
419
+ )}
420
+ </div>
421
+ </div>
422
+ </div>
423
+ );
424
+ }
@@ -1,20 +1,15 @@
1
1
  import { useState, type CSSProperties, type ReactNode } from "react";
2
- import {
3
- CalendarDays,
4
- ChevronLeft,
5
- ChevronRight,
6
- FileText,
7
- Loader2,
8
- MessageSquare,
9
- Mic,
10
- MicOff,
11
- PlayCircle,
12
- Users,
13
- X,
14
- } from "lucide-react";
2
+ import { CalendarDays, ChevronLeft, ChevronRight, MessageSquare, Mic, MicOff, Users, X } from "lucide-react";
15
3
  import { useThemeTokens } from "../../theme/ForgeThemeProvider";
16
4
  import { useMemberCalls, type UseMemberCallsOptions } from "../../headless/memberHome/useMemberCalls";
17
5
  import type { MyMembershipCall } from "../../../data/queries/useMembershipCalls";
6
+ import { CallTranscriptViewer } from "./CallTranscriptViewer";
7
+ import {
8
+ CallRecordingProcessing,
9
+ CallRecordingWatchLink,
10
+ CallTranscriptPanel,
11
+ callRecordingWatchable,
12
+ } from "./CallRecordingActions";
18
13
  import { Loading } from "../Loading";
19
14
 
20
15
  export interface MemberCallsCalendarProps extends UseMemberCallsOptions {
@@ -119,6 +114,8 @@ export function MemberCallsCalendar({
119
114
  // The call whose details modal is open. Clicking an event opens it, the way
120
115
  // Google Calendar opens an event rather than jumping straight into it.
121
116
  const [selected, setSelected] = useState<MyMembershipCall | null>(null);
117
+ // The recording whose transcript is open full-screen, beside its video.
118
+ const [readingRecordingId, setReadingRecordingId] = useState<string | null>(null);
122
119
 
123
120
  if (calendar.isLoading) return <Loading />;
124
121
 
@@ -302,13 +299,23 @@ export function MemberCallsCalendar({
302
299
 
303
300
  {selected && (
304
301
  <CallDetailsModal
305
- call={selected}
302
+ // Re-read from the latest fetch, not the snapshot taken on click, so
303
+ // a transcript that finishes while the modal is open appears in it.
304
+ call={calendar.calls.find((call) => call.id === selected.id) ?? selected}
306
305
  now={now}
307
306
  tokens={t}
308
307
  onClose={() => setSelected(null)}
309
308
  onJoin={() => selected.callPublicId && go(getCallHref(selected.callPublicId))}
309
+ onReadTranscript={(recordingId) => {
310
+ setSelected(null);
311
+ setReadingRecordingId(recordingId);
312
+ }}
310
313
  />
311
314
  )}
315
+
316
+ {readingRecordingId && (
317
+ <CallTranscriptViewer recordingId={readingRecordingId} onClose={() => setReadingRecordingId(null)} />
318
+ )}
312
319
  </div>
313
320
  );
314
321
  }
@@ -370,27 +377,29 @@ function EmptyMonth({
370
377
  * particulars and everything you can DO with it, without leaving the month.
371
378
  *
372
379
  * This is where the actions moved when the chronological list was removed:
373
- * Join while the room is open, Watch once the recording is ready. It is also
374
- * the home for the two things still to come - a transcript and the chat
375
- * history of the call - which are shown as clearly-labelled placeholders
376
- * rather than hidden, so the shape a member will grow into is already visible.
380
+ * Join while the room is open, Watch once the recording is ready, and read
381
+ * the transcript and summary once they are written. The chat history of the
382
+ * call is still to come, and is shown as a clearly-labelled placeholder rather
383
+ * than hidden, so the shape a member will grow into is already visible.
377
384
  */
378
- function CallDetailsModal({
385
+ export function CallDetailsModal({
379
386
  call,
380
387
  now,
381
388
  tokens: t,
382
389
  onClose,
383
390
  onJoin,
391
+ onReadTranscript,
384
392
  }: {
385
393
  call: MyMembershipCall;
386
394
  now: Date;
387
395
  tokens: ReturnType<typeof useThemeTokens>;
388
396
  onClose: () => void;
389
397
  onJoin: () => void;
398
+ onReadTranscript: (recordingId: string) => void;
390
399
  }) {
391
400
  const canJoin = call.isOpen && !!call.callPublicId;
392
401
  const hasEnded = new Date(call.closesAt).getTime() < now.getTime();
393
- const watchable = call.recording?.status === "ready" && !!call.recording.watchUrl;
402
+ const watchable = callRecordingWatchable(call.recording);
394
403
  const processing = call.recording?.status === "processing";
395
404
 
396
405
  const row: CSSProperties = { display: "flex", alignItems: "center", gap: 8, fontSize: 14, color: t.muted };
@@ -539,36 +548,8 @@ function CallDetailsModal({
539
548
  Join call
540
549
  </button>
541
550
  )}
542
- {watchable && (
543
- <a
544
- href={call.recording!.watchUrl!}
545
- target="_blank"
546
- rel="noreferrer"
547
- style={{
548
- display: "flex",
549
- alignItems: "center",
550
- gap: 6,
551
- padding: "10px 20px",
552
- borderRadius: t.cornerRadius,
553
- background: canJoin ? "transparent" : t.primary,
554
- border: canJoin ? `1px solid ${t.border}` : "none",
555
- color: canJoin ? t.text : t.textPrimary,
556
- fontSize: 14,
557
- fontWeight: 700,
558
- textDecoration: "none",
559
- }}
560
- >
561
- <PlayCircle size={16} />
562
- Watch recording
563
- {call.recording!.durationSeconds ? ` · ${Math.round(call.recording!.durationSeconds / 60)} min` : ""}
564
- </a>
565
- )}
566
- {!canJoin && !watchable && processing && (
567
- <span style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 14, color: t.muted, fontWeight: 600 }}>
568
- <Loader2 size={15} />
569
- Recording is processing
570
- </span>
571
- )}
551
+ {watchable && <CallRecordingWatchLink recording={call.recording!} secondary={canJoin} />}
552
+ {!canJoin && !watchable && processing && <CallRecordingProcessing />}
572
553
  {!canJoin && !watchable && !processing && (
573
554
  <span style={{ fontSize: 14, color: t.muted, fontWeight: 600 }}>
574
555
  {hasEnded ? "This call has ended" : opensIn(call.opensAt, now)}
@@ -576,11 +557,17 @@ function CallDetailsModal({
576
557
  )}
577
558
  </div>
578
559
 
579
- {/* What is coming: the transcript and the chat history of the call. Shown
580
- so a member knows the modal is where they will live, not hidden until
581
- the day they ship. */}
560
+ {call.recording?.transcript && (
561
+ <CallTranscriptPanel
562
+ recordingId={call.recording.id}
563
+ transcript={call.recording.transcript}
564
+ onRead={() => onReadTranscript(call.recording!.id)}
565
+ />
566
+ )}
567
+
568
+ {/* What is coming: the chat history of the call. Shown so a member knows
569
+ the modal is where it will live, not hidden until the day it ships. */}
582
570
  <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
583
- {comingSoon(<FileText size={16} />, "Transcript", "A searchable transcript of what was said.")}
584
571
  {comingSoon(<MessageSquare size={16} />, "Chat history", "The messages sent during the call.")}
585
572
  </div>
586
573
  </div>