@tribe-nest/forge 3.42.1 → 3.50.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 (49) hide show
  1. package/package.json +2 -2
  2. package/src/data/queries/useCallChat.ts +162 -0
  3. package/src/data/queries/useCallRecording.ts +80 -0
  4. package/src/data/queries/useMembershipCalls.ts +212 -0
  5. package/src/i18n/de.json +74 -1
  6. package/src/i18n/en.json +74 -1
  7. package/src/index.ts +7 -0
  8. package/src/types/models.ts +58 -5
  9. package/src/ui/headless/memberHome/index.ts +4 -0
  10. package/src/ui/headless/memberHome/useMemberCalls.ts +150 -0
  11. package/src/ui/headless/memberHome/useMemberHomeNav.ts +29 -5
  12. package/src/ui/index.ts +3 -0
  13. package/src/ui/media/CallStage.tsx +691 -553
  14. package/src/ui/media/PreJoin.tsx +269 -0
  15. package/src/ui/media/_tests/CallStage.spec.tsx +338 -5
  16. package/src/ui/media/_tests/callLayout.spec.ts +182 -0
  17. package/src/ui/media/_tests/callSignals.spec.ts +87 -0
  18. package/src/ui/media/_tests/callState.spec.ts +93 -1
  19. package/src/ui/media/_tests/fakeNode.ts +4 -1
  20. package/src/ui/media/callChat.ts +33 -0
  21. package/src/ui/media/callState.ts +125 -9
  22. package/src/ui/media/hooks/useCallSignals.ts +135 -0
  23. package/src/ui/media/hooks/useMeasuredWidth.ts +39 -0
  24. package/src/ui/media/index.ts +41 -1
  25. package/src/ui/media/panels/CallChatPanel.tsx +200 -0
  26. package/src/ui/media/panels/CallParticipantsPanel.tsx +125 -0
  27. package/src/ui/media/panels/CallSidePanel.tsx +152 -0
  28. package/src/ui/media/stage/CallCenterNotice.tsx +89 -0
  29. package/src/ui/media/stage/CallControlsBar.tsx +547 -0
  30. package/src/ui/media/stage/CallHealthPanel.tsx +132 -0
  31. package/src/ui/media/stage/CallToasts.tsx +49 -0
  32. package/src/ui/media/stage/CallTopBar.tsx +257 -0
  33. package/src/ui/media/stage/DeviceMenu.tsx +142 -0
  34. package/src/ui/media/stage/ReactionOverlay.tsx +38 -0
  35. package/src/ui/media/stage/SelfView.tsx +146 -0
  36. package/src/ui/media/stage/VideoTile.tsx +232 -0
  37. package/src/ui/media/stage/callLayout.ts +253 -0
  38. package/src/ui/media/stage/callSignals.ts +91 -0
  39. package/src/ui/media/stage/callSounds.ts +78 -0
  40. package/src/ui/media/stage/stageTheme.ts +50 -0
  41. package/src/ui/styled/CohortPage.tsx +15 -1
  42. package/src/ui/styled/CourseAccess.tsx +19 -6
  43. package/src/ui/styled/CourseCallsSection.tsx +99 -0
  44. package/src/ui/styled/FormRenderer.tsx +14 -4
  45. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +163 -0
  46. package/src/ui/styled/forge-utilities.css +13 -0
  47. package/src/ui/styled/members/MemberCallsCalendar.tsx +589 -0
  48. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +135 -0
  49. package/src/ui/styled/members/index.ts +1 -0
@@ -0,0 +1,589 @@
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";
15
+ import { useThemeTokens } from "../../theme/ForgeThemeProvider";
16
+ import { useMemberCalls, type UseMemberCallsOptions } from "../../headless/memberHome/useMemberCalls";
17
+ import type { MyMembershipCall } from "../../../data/queries/useMembershipCalls";
18
+ import { Loading } from "../Loading";
19
+
20
+ export interface MemberCallsCalendarProps extends UseMemberCallsOptions {
21
+ /**
22
+ * Builds the href of the call itself. Receives the call's public id, which
23
+ * addresses the GENERIC call page - the server decides what the call is
24
+ * attached to and what seat the viewer holds in it.
25
+ */
26
+ getCallHref?: (publicId: string) => string;
27
+ /** SPA navigation adapter. Defaults to a full-page visit. */
28
+ navigate?: (href: string) => void;
29
+ /**
30
+ * Where a viewer with nothing at all is sent to see what a membership
31
+ * includes. Shown only in the empty state.
32
+ *
33
+ * OPTIONAL, and omitting it is how a non-membership surface drops the upsell.
34
+ * A course-only buyer standing in their own classroom with no session this
35
+ * month must not be sold a membership they did not come for, so
36
+ * `CourseCallsSection` passes nothing here. The members calendar passes its
37
+ * path and is unchanged.
38
+ */
39
+ membershipHref?: string;
40
+ className?: string;
41
+ style?: CSSProperties;
42
+ }
43
+
44
+ /** The creator's zone when they set one, else the viewer's. See `useMemberCalls`. */
45
+ const formatTime = (iso: string, timeZone: string | null): string =>
46
+ new Date(iso).toLocaleTimeString("en-US", {
47
+ hour: "numeric",
48
+ minute: "2-digit",
49
+ hour12: true,
50
+ timeZone: timeZone || undefined,
51
+ timeZoneName: "short",
52
+ });
53
+
54
+ const formatMinutes = (startsAt: string, endsAt: string): string => {
55
+ const minutes = Math.round((new Date(endsAt).getTime() - new Date(startsAt).getTime()) / 60_000);
56
+ if (minutes < 60) return `${minutes} min`;
57
+ const hours = Math.floor(minutes / 60);
58
+ const rest = minutes % 60;
59
+ return rest === 0 ? `${hours} hr` : `${hours} hr ${rest} min`;
60
+ };
61
+
62
+ /**
63
+ * "Opens in 3 hours" - what a member is actually waiting for.
64
+ *
65
+ * Counts to `opensAt`, NOT `startsAt`: the room opens fifteen minutes early, and
66
+ * counting to the start would tell somebody to come back at a moment when they
67
+ * could already have been inside.
68
+ */
69
+ const opensIn = (opensAt: string, now: Date): string => {
70
+ const ms = new Date(opensAt).getTime() - now.getTime();
71
+ if (ms <= 0) return "Opening";
72
+ const minutes = Math.round(ms / 60_000);
73
+ if (minutes < 60) return `Opens in ${minutes} min`;
74
+ const hours = Math.round(minutes / 60);
75
+ if (hours < 24) return `Opens in ${hours} ${hours === 1 ? "hour" : "hours"}`;
76
+ const days = Math.round(hours / 24);
77
+ return `Opens in ${days} ${days === 1 ? "day" : "days"}`;
78
+ };
79
+
80
+ const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
81
+
82
+ /**
83
+ * The member's call calendar: a month you can page through, with upcoming calls
84
+ * and past recordings in the same grid.
85
+ *
86
+ * Every call shown is one this viewer may act on - the server filters by the
87
+ * membership it reads for them, so there is no locked state to render and no
88
+ * client-side access check to get wrong. That is a deliberate difference from
89
+ * `PostCollectionsGrid`, which DOES show locked rows: a members-only collection
90
+ * is an offer worth announcing to a non-member, whereas listing a call somebody
91
+ * cannot enter is an invitation to a door that will not open.
92
+ *
93
+ * Nothing here decides whether Join appears: `isOpen` comes from the server,
94
+ * computed from the same window the join enforces. A client re-deriving it from
95
+ * the clock would give two answers to one question, and the wrong one is the one
96
+ * the member acts on.
97
+ *
98
+ * ## It is not a MEMBERSHIP calendar, only a member-area one
99
+ *
100
+ * The rows come from `listForAttendee`, which resolves every audience kind, so
101
+ * this same grid renders a course's office hours and a cohort's week-3 session
102
+ * without knowing what either is. `courseId` / `cohortId` (through
103
+ * `UseMemberCallsOptions`) narrow it to one of them, which is what lets the
104
+ * classroom and the cohort page reuse this component instead of growing a
105
+ * second month grid. See `CourseCallsSection`.
106
+ */
107
+ export function MemberCallsCalendar({
108
+ getCallHref = (publicId) => `/i/video-calls/${publicId}`,
109
+ navigate,
110
+ membershipHref,
111
+ className,
112
+ style,
113
+ ...options
114
+ }: MemberCallsCalendarProps) {
115
+ const t = useThemeTokens();
116
+ const calendar = useMemberCalls(options);
117
+ const go = navigate ?? ((href: string) => window.location.assign(href));
118
+ const now = options.now ?? new Date();
119
+ // The call whose details modal is open. Clicking an event opens it, the way
120
+ // Google Calendar opens an event rather than jumping straight into it.
121
+ const [selected, setSelected] = useState<MyMembershipCall | null>(null);
122
+
123
+ if (calendar.isLoading) return <Loading />;
124
+
125
+ if (calendar.error) {
126
+ return (
127
+ <p style={{ color: t.muted, fontFamily: t.fontFamily, fontSize: 14 }}>
128
+ We could not load your calls just now. Refresh to try again.
129
+ </p>
130
+ );
131
+ }
132
+
133
+ const navButton: CSSProperties = {
134
+ display: "inline-flex",
135
+ alignItems: "center",
136
+ gap: 4,
137
+ padding: "6px 10px",
138
+ borderRadius: t.cornerRadius,
139
+ border: `1px solid ${t.border}`,
140
+ background: "transparent",
141
+ color: t.text,
142
+ fontFamily: t.fontFamily,
143
+ fontSize: 13,
144
+ cursor: "pointer",
145
+ };
146
+
147
+ return (
148
+ <div
149
+ className={className}
150
+ style={{ display: "flex", flexDirection: "column", gap: 16, color: t.text, fontFamily: t.fontFamily, ...style }}
151
+ >
152
+ <header style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
153
+ <h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, flex: "1 1 auto" }}>{calendar.monthLabel}</h2>
154
+ <button type="button" onClick={calendar.goToPreviousMonth} aria-label="Previous month" style={navButton}>
155
+ <ChevronLeft size={16} />
156
+ </button>
157
+ {/* Only offered when it would do something. A "Today" that is already
158
+ today is a control that does nothing when pressed. */}
159
+ {!calendar.isCurrentMonth && (
160
+ <button type="button" onClick={calendar.goToToday} style={navButton}>
161
+ Today
162
+ </button>
163
+ )}
164
+ <button type="button" onClick={calendar.goToNextMonth} aria-label="Next month" style={navButton}>
165
+ <ChevronRight size={16} />
166
+ </button>
167
+ </header>
168
+
169
+ {/* A seven-column month is unreadable on a phone and overflows it
170
+ sideways, so the grid is the DESKTOP face and a phone gets an agenda
171
+ (below). Both open the same details modal on tap. `minmax(0, 1fr)`
172
+ rather than `1fr` so a long title in a cell cannot force the grid
173
+ wider than its container. */}
174
+ <div className="hidden sm:block">
175
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(7, minmax(0, 1fr))", gap: 4 }}>
176
+ {WEEKDAYS.map((day) => (
177
+ <div key={day} style={{ fontSize: 11, fontWeight: 700, color: t.muted, padding: "4px 6px" }}>
178
+ {day}
179
+ </div>
180
+ ))}
181
+ {calendar.days.map((day) => (
182
+ <div
183
+ key={day.key}
184
+ aria-current={day.isToday ? "date" : undefined}
185
+ style={{
186
+ minHeight: 72,
187
+ padding: 6,
188
+ borderRadius: t.cornerRadius,
189
+ border: `1px solid ${day.isToday ? t.primary : t.border}`,
190
+ background: day.inMonth ? t.surface : "transparent",
191
+ // Days outside the month are context, not content.
192
+ opacity: day.inMonth ? 1 : 0.45,
193
+ display: "flex",
194
+ flexDirection: "column",
195
+ gap: 3,
196
+ }}
197
+ >
198
+ <span style={{ fontSize: 11, fontWeight: day.isToday ? 700 : 500, color: t.muted }}>
199
+ {day.date.getDate()}
200
+ </span>
201
+ {day.calls.map((call) => (
202
+ <button
203
+ key={call.id}
204
+ type="button"
205
+ onClick={() => setSelected(call)}
206
+ title={call.title}
207
+ style={{
208
+ textAlign: "left",
209
+ padding: "2px 4px",
210
+ borderRadius: 4,
211
+ border: "none",
212
+ cursor: "pointer",
213
+ fontSize: 11,
214
+ lineHeight: 1.3,
215
+ overflow: "hidden",
216
+ textOverflow: "ellipsis",
217
+ whiteSpace: "nowrap",
218
+ // Open now is the one thing worth shouting about in a cell
219
+ // this small.
220
+ background: call.isOpen ? t.primary : "transparent",
221
+ color: call.isOpen ? t.textPrimary : t.text,
222
+ }}
223
+ >
224
+ {call.title}
225
+ </button>
226
+ ))}
227
+ </div>
228
+ ))}
229
+ </div>
230
+ </div>
231
+
232
+ {/* The phone face: an agenda, not a grid. A vertical list cannot overflow
233
+ a narrow screen sideways, and every row is a comfortable tap target
234
+ that opens the same details modal. Only days WITH a call appear. */}
235
+ <div className="sm:hidden">
236
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
237
+ {calendar.days
238
+ .filter((day) => day.inMonth && day.calls.length > 0)
239
+ .map((day) => (
240
+ <div key={day.key} style={{ display: "flex", flexDirection: "column", gap: 6 }}>
241
+ <div
242
+ style={{
243
+ fontSize: 12,
244
+ fontWeight: 700,
245
+ color: day.isToday ? t.primary : t.muted,
246
+ textTransform: "uppercase",
247
+ letterSpacing: 0.4,
248
+ }}
249
+ >
250
+ {day.date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })}
251
+ </div>
252
+ {day.calls.map((call) => (
253
+ <button
254
+ key={call.id}
255
+ type="button"
256
+ onClick={() => setSelected(call)}
257
+ style={{
258
+ display: "flex",
259
+ alignItems: "center",
260
+ gap: 10,
261
+ width: "100%",
262
+ textAlign: "left",
263
+ padding: "10px 12px",
264
+ borderRadius: t.cornerRadius,
265
+ border: `1px solid ${call.isOpen ? t.primary : t.border}`,
266
+ background: t.surface,
267
+ color: t.text,
268
+ fontFamily: t.fontFamily,
269
+ cursor: "pointer",
270
+ }}
271
+ >
272
+ <span
273
+ aria-hidden="true"
274
+ style={{
275
+ flexShrink: 0,
276
+ width: 8,
277
+ height: 8,
278
+ borderRadius: 999,
279
+ background: call.isOpen ? t.primary : t.muted,
280
+ }}
281
+ />
282
+ <span style={{ minWidth: 0, display: "flex", flexDirection: "column", gap: 2 }}>
283
+ <span style={{ fontSize: 14, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
284
+ {call.title}
285
+ </span>
286
+ <span style={{ fontSize: 12, color: t.muted }}>
287
+ {formatTime(call.startsAt, call.timezone)}
288
+ {call.isOpen ? " · Open now" : ""}
289
+ </span>
290
+ </span>
291
+ </button>
292
+ ))}
293
+ </div>
294
+ ))}
295
+ </div>
296
+ </div>
297
+
298
+ {/* No list any more: the calendar IS the surface, and an event opens its
299
+ own details. Only a wholly empty month gets a word, so the grid does
300
+ not sit above a silent gap. */}
301
+ {calendar.calls.length === 0 && <EmptyMonth tokens={t} membershipHref={membershipHref} onNavigate={go} />}
302
+
303
+ {selected && (
304
+ <CallDetailsModal
305
+ call={selected}
306
+ now={now}
307
+ tokens={t}
308
+ onClose={() => setSelected(null)}
309
+ onJoin={() => selected.callPublicId && go(getCallHref(selected.callPublicId))}
310
+ />
311
+ )}
312
+ </div>
313
+ );
314
+ }
315
+
316
+ function EmptyMonth({
317
+ tokens: t,
318
+ membershipHref,
319
+ onNavigate,
320
+ }: {
321
+ tokens: ReturnType<typeof useThemeTokens>;
322
+ membershipHref?: string;
323
+ onNavigate: (href: string) => void;
324
+ }) {
325
+ return (
326
+ <div
327
+ style={{
328
+ display: "flex",
329
+ flexDirection: "column",
330
+ alignItems: "center",
331
+ gap: 8,
332
+ padding: "32px 24px",
333
+ textAlign: "center",
334
+ border: `1px solid ${t.border}`,
335
+ borderRadius: t.cornerRadius,
336
+ background: t.surface,
337
+ }}
338
+ >
339
+ <CalendarDays size={26} style={{ opacity: 0.5 }} />
340
+ <p style={{ fontSize: 15, fontWeight: 600, margin: 0 }}>Nothing this month</p>
341
+ <p style={{ fontSize: 14, color: t.muted, margin: 0, maxWidth: 380 }}>
342
+ Use the arrows to look at another month. Calls you can join show up here, and so do recordings of
343
+ ones that have already happened.
344
+ </p>
345
+ {membershipHref && (
346
+ <button
347
+ type="button"
348
+ onClick={() => onNavigate(membershipHref)}
349
+ style={{
350
+ marginTop: 4,
351
+ padding: "8px 16px",
352
+ borderRadius: t.cornerRadius,
353
+ border: `1px solid ${t.border}`,
354
+ background: "transparent",
355
+ color: t.text,
356
+ fontFamily: t.fontFamily,
357
+ fontSize: 14,
358
+ cursor: "pointer",
359
+ }}
360
+ >
361
+ My membership
362
+ </button>
363
+ )}
364
+ </div>
365
+ );
366
+ }
367
+
368
+ /**
369
+ * The event details, the way Google Calendar shows one: tap an event, get its
370
+ * particulars and everything you can DO with it, without leaving the month.
371
+ *
372
+ * 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.
377
+ */
378
+ function CallDetailsModal({
379
+ call,
380
+ now,
381
+ tokens: t,
382
+ onClose,
383
+ onJoin,
384
+ }: {
385
+ call: MyMembershipCall;
386
+ now: Date;
387
+ tokens: ReturnType<typeof useThemeTokens>;
388
+ onClose: () => void;
389
+ onJoin: () => void;
390
+ }) {
391
+ const canJoin = call.isOpen && !!call.callPublicId;
392
+ const hasEnded = new Date(call.closesAt).getTime() < now.getTime();
393
+ const watchable = call.recording?.status === "ready" && !!call.recording.watchUrl;
394
+ const processing = call.recording?.status === "processing";
395
+
396
+ const row: CSSProperties = { display: "flex", alignItems: "center", gap: 8, fontSize: 14, color: t.muted };
397
+
398
+ /** A future capability, present but plainly not ready. Named so a member
399
+ * knows it is coming, greyed so nobody mistakes it for a dead control. */
400
+ const comingSoon = (icon: ReactNode, label: string, detail: string) => (
401
+ <div
402
+ style={{
403
+ display: "flex",
404
+ alignItems: "flex-start",
405
+ gap: 10,
406
+ padding: "10px 12px",
407
+ borderRadius: t.cornerRadius,
408
+ border: `1px dashed ${t.border}`,
409
+ opacity: 0.7,
410
+ }}
411
+ >
412
+ <span style={{ marginTop: 1 }}>{icon}</span>
413
+ <div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
414
+ <span style={{ fontSize: 14, fontWeight: 600, display: "flex", alignItems: "center", gap: 8 }}>
415
+ {label}
416
+ <span
417
+ style={{
418
+ fontSize: 10,
419
+ fontWeight: 700,
420
+ textTransform: "uppercase",
421
+ letterSpacing: 0.5,
422
+ color: t.muted,
423
+ border: `1px solid ${t.border}`,
424
+ borderRadius: 999,
425
+ padding: "1px 6px",
426
+ }}
427
+ >
428
+ Coming soon
429
+ </span>
430
+ </span>
431
+ <span style={{ fontSize: 13, color: t.muted }}>{detail}</span>
432
+ </div>
433
+ </div>
434
+ );
435
+
436
+ return (
437
+ <div
438
+ role="dialog"
439
+ aria-modal="true"
440
+ aria-label={call.title}
441
+ onClick={onClose}
442
+ style={{
443
+ position: "fixed",
444
+ inset: 0,
445
+ zIndex: 1000,
446
+ display: "flex",
447
+ alignItems: "center",
448
+ justifyContent: "center",
449
+ padding: 16,
450
+ background: "rgba(0,0,0,0.5)",
451
+ }}
452
+ >
453
+ <div
454
+ onClick={(e) => e.stopPropagation()}
455
+ style={{
456
+ width: "100%",
457
+ maxWidth: 460,
458
+ maxHeight: "85vh",
459
+ overflowY: "auto",
460
+ display: "flex",
461
+ flexDirection: "column",
462
+ gap: 16,
463
+ padding: 20,
464
+ borderRadius: t.cornerRadius,
465
+ background: t.surface,
466
+ color: t.text,
467
+ fontFamily: t.fontFamily,
468
+ border: `1px solid ${t.border}`,
469
+ }}
470
+ >
471
+ <header style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
472
+ <div style={{ minWidth: 0, flex: 1, display: "flex", flexDirection: "column", gap: 4 }}>
473
+ <h2 style={{ margin: 0, fontSize: 18, fontWeight: 700 }}>{call.title}</h2>
474
+ {call.role === "host" && (
475
+ <span style={{ fontSize: 11, fontWeight: 700, color: t.muted, textTransform: "uppercase" }}>
476
+ You are hosting
477
+ </span>
478
+ )}
479
+ </div>
480
+ <button
481
+ type="button"
482
+ onClick={onClose}
483
+ aria-label="Close"
484
+ style={{ background: "transparent", border: "none", cursor: "pointer", color: t.muted, padding: 2 }}
485
+ >
486
+ <X size={18} />
487
+ </button>
488
+ </header>
489
+
490
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
491
+ <div style={row}>
492
+ <CalendarDays size={15} />
493
+ <span style={{ color: t.text }}>
494
+ {new Date(call.startsAt).toLocaleDateString("en-US", {
495
+ weekday: "long",
496
+ month: "long",
497
+ day: "numeric",
498
+ timeZone: call.timezone || undefined,
499
+ })}
500
+ {" · "}
501
+ {formatTime(call.startsAt, call.timezone)} · {formatMinutes(call.startsAt, call.endsAt)}
502
+ </span>
503
+ </div>
504
+ {!hasEnded && (
505
+ <div style={row}>
506
+ {call.participation === "open" ? <Mic size={15} /> : <MicOff size={15} />}
507
+ {call.participation === "open" ? "Everyone can speak" : "Listen and chat"}
508
+ </div>
509
+ )}
510
+ {call.audiences.length > 0 && (
511
+ <div style={row}>
512
+ <Users size={15} />
513
+ {call.audiences.map((audience) => audience.label).join(", ")}
514
+ </div>
515
+ )}
516
+ {call.description && (
517
+ <p style={{ margin: "4px 0 0", fontSize: 14, color: t.text, opacity: 0.85 }}>{call.description}</p>
518
+ )}
519
+ </div>
520
+
521
+ {/* What a member can do RIGHT NOW. */}
522
+ <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
523
+ {canJoin && (
524
+ <button
525
+ type="button"
526
+ onClick={onJoin}
527
+ style={{
528
+ padding: "10px 20px",
529
+ borderRadius: t.cornerRadius,
530
+ border: "none",
531
+ background: t.primary,
532
+ color: t.textPrimary,
533
+ fontFamily: t.fontFamily,
534
+ fontSize: 14,
535
+ fontWeight: 700,
536
+ cursor: "pointer",
537
+ }}
538
+ >
539
+ Join call
540
+ </button>
541
+ )}
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
+ )}
572
+ {!canJoin && !watchable && !processing && (
573
+ <span style={{ fontSize: 14, color: t.muted, fontWeight: 600 }}>
574
+ {hasEnded ? "This call has ended" : opensIn(call.opensAt, now)}
575
+ </span>
576
+ )}
577
+ </div>
578
+
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. */}
582
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
583
+ {comingSoon(<FileText size={16} />, "Transcript", "A searchable transcript of what was said.")}
584
+ {comingSoon(<MessageSquare size={16} />, "Chat history", "The messages sent during the call.")}
585
+ </div>
586
+ </div>
587
+ </div>
588
+ );
589
+ }