@tribe-nest/forge 3.59.0 → 3.60.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 (94) hide show
  1. package/package.json +1 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CommunityContext.tsx +192 -0
  4. package/src/data/queries/useChat.ts +42 -39
  5. package/src/data/queries/useCommunities.ts +343 -0
  6. package/src/data/queries/useCommunity.ts +93 -89
  7. package/src/data/queries/useMembership.ts +32 -7
  8. package/src/data/queries/useMembershipCalls.ts +116 -7
  9. package/src/data/queries/usePostCollections.ts +28 -16
  10. package/src/data/queries/usePosts.ts +16 -14
  11. package/src/data/queries/useSubscriptions.ts +22 -8
  12. package/src/i18n/de.json +145 -1
  13. package/src/i18n/en.json +145 -1
  14. package/src/index.ts +19 -7
  15. package/src/runtime/pages.spec.ts +31 -0
  16. package/src/runtime/pages.ts +21 -22
  17. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  18. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  19. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  20. package/src/ui/format/membershipGate.ts +19 -15
  21. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  22. package/src/ui/headless/_tests/communityHarness.tsx +205 -0
  23. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  24. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  25. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +382 -0
  26. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +151 -0
  27. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +286 -0
  28. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  29. package/src/ui/headless/auth/useSignupForm.ts +10 -2
  30. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  31. package/src/ui/headless/communities/index.ts +55 -0
  32. package/src/ui/headless/communities/paths.ts +36 -0
  33. package/src/ui/headless/communities/useCommunity.ts +140 -0
  34. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  35. package/src/ui/headless/communities/useCommunityHome.ts +88 -0
  36. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  37. package/src/ui/headless/communities/useCommunityJoin.ts +444 -0
  38. package/src/ui/headless/communities/useCommunityMembership.ts +136 -0
  39. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  40. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  41. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  42. package/src/ui/headless/index.ts +8 -4
  43. package/src/ui/headless/memberHome/index.ts +1 -1
  44. package/src/ui/headless/memberHome/useMemberHomeNav.ts +44 -42
  45. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  46. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  47. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  48. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  49. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  50. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  51. package/src/ui/headless/posts/upsell.ts +54 -8
  52. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  53. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  54. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  55. package/src/ui/headless/posts/usePostItem.ts +6 -5
  56. package/src/ui/index.ts +17 -12
  57. package/src/ui/styled/AccountDashboard.tsx +48 -256
  58. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  59. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  60. package/src/ui/styled/MembershipTiers.tsx +49 -11
  61. package/src/ui/styled/Paywall.tsx +1 -1
  62. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +1 -1
  63. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  64. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  65. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  66. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  67. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  68. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  69. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  70. package/src/ui/styled/communities/CommunityAbout.tsx +140 -0
  71. package/src/ui/styled/communities/CommunityHome.tsx +175 -0
  72. package/src/ui/styled/communities/CommunityJoinSheet.tsx +444 -0
  73. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +133 -0
  74. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  75. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  76. package/src/ui/styled/communities/MyMembershipsList.tsx +89 -0
  77. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  78. package/src/ui/styled/communities/index.ts +10 -0
  79. package/src/ui/styled/communities/shared.tsx +149 -0
  80. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  81. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  82. package/src/ui/styled/forge-utilities.css +19 -0
  83. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  84. package/src/ui/styled/members/MemberCallsCalendar.tsx +142 -10
  85. package/src/ui/styled/members/MemberHomeLayout.tsx +35 -17
  86. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  87. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  88. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  89. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  90. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  91. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  92. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  93. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  94. 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
+ }
@@ -3,6 +3,7 @@ import {
3
3
  CalendarDays,
4
4
  ChevronLeft,
5
5
  ChevronRight,
6
+ Download,
6
7
  FileText,
7
8
  Loader2,
8
9
  MessageSquare,
@@ -14,7 +15,16 @@ import {
14
15
  } from "lucide-react";
15
16
  import { useThemeTokens } from "../../theme/ForgeThemeProvider";
16
17
  import { useMemberCalls, type UseMemberCallsOptions } from "../../headless/memberHome/useMemberCalls";
17
- import type { MyMembershipCall } from "../../../data/queries/useMembershipCalls";
18
+ import { useQueryClient } from "@tanstack/react-query";
19
+ import { useForge } from "../../../provider/ForgeProvider";
20
+ import {
21
+ fetchMemberCallTranscript,
22
+ memberCallTranscriptKey,
23
+ type MemberCallTranscript,
24
+ type MyMembershipCall,
25
+ } from "../../../data/queries/useMembershipCalls";
26
+ import { CallTranscriptViewer } from "./CallTranscriptViewer";
27
+ import { downloadTranscript } from "./callTranscriptText";
18
28
  import { Loading } from "../Loading";
19
29
 
20
30
  export interface MemberCallsCalendarProps extends UseMemberCallsOptions {
@@ -119,6 +129,8 @@ export function MemberCallsCalendar({
119
129
  // The call whose details modal is open. Clicking an event opens it, the way
120
130
  // Google Calendar opens an event rather than jumping straight into it.
121
131
  const [selected, setSelected] = useState<MyMembershipCall | null>(null);
132
+ // The recording whose transcript is open full-screen, beside its video.
133
+ const [readingRecordingId, setReadingRecordingId] = useState<string | null>(null);
122
134
 
123
135
  if (calendar.isLoading) return <Loading />;
124
136
 
@@ -302,13 +314,23 @@ export function MemberCallsCalendar({
302
314
 
303
315
  {selected && (
304
316
  <CallDetailsModal
305
- call={selected}
317
+ // Re-read from the latest fetch, not the snapshot taken on click, so
318
+ // a transcript that finishes while the modal is open appears in it.
319
+ call={calendar.calls.find((call) => call.id === selected.id) ?? selected}
306
320
  now={now}
307
321
  tokens={t}
308
322
  onClose={() => setSelected(null)}
309
323
  onJoin={() => selected.callPublicId && go(getCallHref(selected.callPublicId))}
324
+ onReadTranscript={(recordingId) => {
325
+ setSelected(null);
326
+ setReadingRecordingId(recordingId);
327
+ }}
310
328
  />
311
329
  )}
330
+
331
+ {readingRecordingId && (
332
+ <CallTranscriptViewer recordingId={readingRecordingId} onClose={() => setReadingRecordingId(null)} />
333
+ )}
312
334
  </div>
313
335
  );
314
336
  }
@@ -370,10 +392,10 @@ function EmptyMonth({
370
392
  * particulars and everything you can DO with it, without leaving the month.
371
393
  *
372
394
  * 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.
395
+ * Join while the room is open, Watch once the recording is ready, and read
396
+ * the transcript and summary once they are written. The chat history of the
397
+ * call is still to come, and is shown as a clearly-labelled placeholder rather
398
+ * than hidden, so the shape a member will grow into is already visible.
377
399
  */
378
400
  function CallDetailsModal({
379
401
  call,
@@ -381,12 +403,14 @@ function CallDetailsModal({
381
403
  tokens: t,
382
404
  onClose,
383
405
  onJoin,
406
+ onReadTranscript,
384
407
  }: {
385
408
  call: MyMembershipCall;
386
409
  now: Date;
387
410
  tokens: ReturnType<typeof useThemeTokens>;
388
411
  onClose: () => void;
389
412
  onJoin: () => void;
413
+ onReadTranscript: (recordingId: string) => void;
390
414
  }) {
391
415
  const canJoin = call.isOpen && !!call.callPublicId;
392
416
  const hasEnded = new Date(call.closesAt).getTime() < now.getTime();
@@ -576,14 +600,122 @@ function CallDetailsModal({
576
600
  )}
577
601
  </div>
578
602
 
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. */}
603
+ {call.recording?.transcript && (
604
+ <TranscriptPanel
605
+ recordingId={call.recording.id}
606
+ transcript={call.recording.transcript}
607
+ tokens={t}
608
+ onRead={() => onReadTranscript(call.recording!.id)}
609
+ />
610
+ )}
611
+
612
+ {/* What is coming: the chat history of the call. Shown so a member knows
613
+ the modal is where it will live, not hidden until the day it ships. */}
582
614
  <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
583
- {comingSoon(<FileText size={16} />, "Transcript", "A searchable transcript of what was said.")}
584
615
  {comingSoon(<MessageSquare size={16} />, "Chat history", "The messages sent during the call.")}
585
616
  </div>
586
617
  </div>
587
618
  </div>
588
619
  );
589
620
  }
621
+
622
+ /**
623
+ * The call's summary, and the way into its transcript.
624
+ *
625
+ * The lines are NOT here: a two-hour meeting is thousands of them, which is
626
+ * unusable in a modal. "Read transcript" opens them beside the video, and
627
+ * Download reads them once without opening anything. A member sees the
628
+ * finished thing or that it is being prepared, never a failure: why one could
629
+ * not be made is the business's problem, not its audience's.
630
+ */
631
+ function TranscriptPanel({
632
+ recordingId,
633
+ transcript,
634
+ tokens: t,
635
+ onRead,
636
+ }: {
637
+ recordingId: string;
638
+ transcript: MemberCallTranscript;
639
+ tokens: ReturnType<typeof useThemeTokens>;
640
+ onRead: () => void;
641
+ }) {
642
+ const { client, profileId } = useForge();
643
+ const queryClient = useQueryClient();
644
+ const [downloading, setDownloading] = useState(false);
645
+ const heading: CSSProperties = { display: "flex", alignItems: "center", gap: 8, fontSize: 14, fontWeight: 700 };
646
+ const button: CSSProperties = {
647
+ display: "inline-flex",
648
+ alignItems: "center",
649
+ gap: 6,
650
+ padding: "8px 14px",
651
+ borderRadius: t.cornerRadius,
652
+ border: `1px solid ${t.border}`,
653
+ background: "transparent",
654
+ color: t.text,
655
+ fontFamily: t.fontFamily,
656
+ fontSize: 14,
657
+ fontWeight: 600,
658
+ cursor: "pointer",
659
+ };
660
+
661
+ if (transcript.status === "processing") {
662
+ return (
663
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }} data-testid="member-call-transcript">
664
+ <span style={heading}>
665
+ <FileText size={16} />
666
+ Transcript
667
+ </span>
668
+ <span style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 14, color: t.muted }} role="status">
669
+ <Loader2 size={15} />
670
+ The transcript is being prepared
671
+ </span>
672
+ </div>
673
+ );
674
+ }
675
+
676
+ const download = async () => {
677
+ setDownloading(true);
678
+ try {
679
+ const detail = await queryClient.fetchQuery({
680
+ queryKey: memberCallTranscriptKey(profileId, recordingId),
681
+ queryFn: () => fetchMemberCallTranscript(client, profileId, recordingId),
682
+ });
683
+ downloadTranscript(detail);
684
+ } finally {
685
+ setDownloading(false);
686
+ }
687
+ };
688
+
689
+ return (
690
+ <div style={{ display: "flex", flexDirection: "column", gap: 10 }} data-testid="member-call-transcript">
691
+ <span style={heading}>
692
+ <FileText size={16} />
693
+ Summary
694
+ </span>
695
+ <p
696
+ style={{
697
+ margin: 0,
698
+ fontSize: 14,
699
+ lineHeight: 1.5,
700
+ display: "-webkit-box",
701
+ WebkitLineClamp: 4,
702
+ WebkitBoxOrient: "vertical",
703
+ overflow: "hidden",
704
+ color: transcript.summary ? t.text : t.muted,
705
+ }}
706
+ >
707
+ {transcript.summary ? transcript.summary.summary : "The transcript is ready."}
708
+ </p>
709
+ <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
710
+ <button type="button" onClick={onRead} style={button}>
711
+ <FileText size={15} />
712
+ Read transcript
713
+ </button>
714
+ <button type="button" onClick={() => void download()} disabled={downloading} style={button}>
715
+ {downloading ? <Loader2 size={15} /> : <Download size={15} />}
716
+ Download
717
+ </button>
718
+ </div>
719
+ </div>
720
+ );
721
+ }