@tribe-nest/forge 3.26.0 → 3.31.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 (129) hide show
  1. package/package.json +6 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  6. package/src/client/activeLocale.ts +34 -0
  7. package/src/client/createForgeClient.ts +13 -0
  8. package/src/contexts/CartContext.tsx +76 -0
  9. package/src/contexts/PublicAuthContext.tsx +34 -5
  10. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  11. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  12. package/src/data/queries/useBroadcasts.ts +151 -0
  13. package/src/data/queries/useCheckouts.ts +83 -0
  14. package/src/data/queries/useMyBookings.ts +9 -1
  15. package/src/data/queries/useWebsite.ts +7 -0
  16. package/src/i18n/CONVENTION.md +184 -0
  17. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  18. package/src/i18n/de.json +1019 -0
  19. package/src/i18n/en.json +1019 -0
  20. package/src/i18n/index.ts +125 -0
  21. package/src/index.ts +21 -0
  22. package/src/provider/ForgeAppProvider.tsx +2 -1
  23. package/src/provider/SiteConfigProvider.tsx +11 -0
  24. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  25. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  26. package/src/ui/format/membershipPwyw.ts +164 -0
  27. package/src/ui/format/pwyw.ts +37 -0
  28. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  29. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  30. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  31. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  32. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  33. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  34. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  35. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  36. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  37. package/src/ui/headless/index.ts +32 -0
  38. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  39. package/src/ui/index.ts +38 -0
  40. package/src/ui/media/CallHelpHint.tsx +87 -0
  41. package/src/ui/media/CallStage.tsx +542 -0
  42. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  43. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  44. package/src/ui/media/_tests/callState.spec.ts +452 -0
  45. package/src/ui/media/_tests/fakeNode.ts +178 -0
  46. package/src/ui/media/bookingSession.tsx +194 -0
  47. package/src/ui/media/callState.ts +341 -0
  48. package/src/ui/media/index.ts +135 -0
  49. package/src/ui/shell/TribeNestApp.tsx +36 -27
  50. package/src/ui/styled/AccountDashboard.tsx +289 -96
  51. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  52. package/src/ui/styled/AudioPlayer.tsx +14 -6
  53. package/src/ui/styled/BlogCategory.tsx +4 -2
  54. package/src/ui/styled/BlogComments.tsx +27 -20
  55. package/src/ui/styled/BlogList.tsx +5 -3
  56. package/src/ui/styled/BlogPost.tsx +14 -11
  57. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  58. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  59. package/src/ui/styled/CancellationTerms.tsx +3 -1
  60. package/src/ui/styled/Cart.tsx +32 -14
  61. package/src/ui/styled/ChatRoom.tsx +65 -20
  62. package/src/ui/styled/Checkout.tsx +151 -89
  63. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  64. package/src/ui/styled/CoachingBooking.tsx +71 -44
  65. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  66. package/src/ui/styled/CoachingDetail.tsx +15 -11
  67. package/src/ui/styled/CohortPage.tsx +10 -8
  68. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  69. package/src/ui/styled/ContactForm.tsx +31 -12
  70. package/src/ui/styled/CookieConsent.tsx +19 -15
  71. package/src/ui/styled/CourseAccess.tsx +54 -47
  72. package/src/ui/styled/CourseCheckout.tsx +56 -38
  73. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  74. package/src/ui/styled/CourseDetail.tsx +21 -15
  75. package/src/ui/styled/CoursesGrid.tsx +4 -2
  76. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  77. package/src/ui/styled/DiscountCode.tsx +22 -18
  78. package/src/ui/styled/DonationButton.tsx +52 -17
  79. package/src/ui/styled/DonationPage.tsx +4 -4
  80. package/src/ui/styled/EmailListForm.tsx +22 -9
  81. package/src/ui/styled/EventConfirmation.tsx +37 -14
  82. package/src/ui/styled/EventCountdown.tsx +10 -6
  83. package/src/ui/styled/EventDetail.tsx +8 -6
  84. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  85. package/src/ui/styled/EventTickets.tsx +232 -108
  86. package/src/ui/styled/EventWaitlist.tsx +42 -33
  87. package/src/ui/styled/EventsList.tsx +15 -13
  88. package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
  89. package/src/ui/styled/FormRenderer.tsx +12 -7
  90. package/src/ui/styled/InstallBanner.tsx +21 -18
  91. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  92. package/src/ui/styled/InvoicePayment.tsx +37 -16
  93. package/src/ui/styled/LeadMagnet.tsx +9 -7
  94. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  95. package/src/ui/styled/Loading.tsx +7 -1
  96. package/src/ui/styled/LoginForm.tsx +32 -14
  97. package/src/ui/styled/MembershipCheckout.tsx +341 -49
  98. package/src/ui/styled/MembershipTiers.tsx +34 -18
  99. package/src/ui/styled/OfferButton.tsx +33 -13
  100. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  101. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  102. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  103. package/src/ui/styled/PodcastList.tsx +9 -3
  104. package/src/ui/styled/PodcastShow.tsx +29 -7
  105. package/src/ui/styled/PostsFeed.tsx +10 -8
  106. package/src/ui/styled/PresaleCode.tsx +8 -11
  107. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  108. package/src/ui/styled/ProductDetail.tsx +58 -28
  109. package/src/ui/styled/ReactionBar.tsx +4 -2
  110. package/src/ui/styled/ReplayList.tsx +24 -13
  111. package/src/ui/styled/ResetPasswordForm.tsx +32 -15
  112. package/src/ui/styled/ResumeCart.tsx +289 -0
  113. package/src/ui/styled/ReviewForm.tsx +52 -58
  114. package/src/ui/styled/SignupForm.tsx +34 -16
  115. package/src/ui/styled/TicketTransfer.tsx +55 -54
  116. package/src/ui/styled/UserMenu.tsx +16 -14
  117. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  118. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  119. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  120. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
  121. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  122. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  123. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  124. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  125. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  126. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  127. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  128. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  129. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
@@ -0,0 +1,536 @@
1
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import { Maximize, Pause, Pin, Play, Send } from "lucide-react";
3
+ import { io, type Socket } from "socket.io-client";
4
+ import type { IBroadcastPass, ILiveBroadcast } from "../../../types/models";
5
+ import { useForge } from "../../../provider/ForgeProvider";
6
+ import { getForgeWebSocketUrl } from "../../../client/createForgeClient";
7
+ import {
8
+ BroadcastChannelProvider,
9
+ useBroadcastAudience,
10
+ useBroadcastComments,
11
+ useBroadcastSessionApi,
12
+ type BroadcastComment,
13
+ } from "../../../data/queries/useBroadcasts";
14
+ import { useForgeT } from "../../../i18n";
15
+ import { useForgeTheme } from "../../theme/ForgeThemeProvider";
16
+ import { timeAgo } from "../community/util";
17
+ import { buttonStyle } from "../Button";
18
+ import { Loading } from "../Loading";
19
+
20
+ /** The socket messages a watching browser sends and receives. */
21
+ const SocketEvent = {
22
+ BroadcastChatMessage: "broadcastChatMessage",
23
+ BroadcastJoin: "broadcastJoin",
24
+ BroadcastPinComment: "broadcastPinComment",
25
+ } as const;
26
+
27
+ /**
28
+ * The simulcast layers a viewer can pin themselves to. The one-letter values are
29
+ * the encoder's rids, not labels: `f` full, `h` half, `q` quarter.
30
+ */
31
+ const SIMULCAST_QUALITIES = [
32
+ { label: "forge.broadcast_player.quality_auto", value: "auto" },
33
+ { label: "forge.broadcast_player.quality_high", value: "f" },
34
+ { label: "forge.broadcast_player.quality_medium", value: "h" },
35
+ { label: "forge.broadcast_player.quality_low", value: "q" },
36
+ ] as const;
37
+
38
+ const newId = (): string => {
39
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
40
+ return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
41
+ };
42
+
43
+ export interface BroadcastPlayerRenderProps {
44
+ /** The HLS playlist to play. */
45
+ src?: string;
46
+ }
47
+
48
+ export interface BroadcastPlayerProps {
49
+ broadcast: ILiveBroadcast;
50
+ /** The validated pass. Its name is what this viewer's chat messages are signed with. */
51
+ broadcastPass: IBroadcastPass;
52
+ /**
53
+ * How to play the HLS stream.
54
+ *
55
+ * Forge does not ship an HLS engine: a `<video src="…m3u8">` plays natively on
56
+ * Safari and on nothing else, so a site that wants Chrome and Firefox passes a
57
+ * player here (the starter passes `react-player`, which is exactly what
58
+ * apps/client uses). The bare `<video>` is the fallback rather than the
59
+ * default-and-only, so a missing renderer degrades to "plays on Safari"
60
+ * instead of to a blank rectangle everywhere.
61
+ */
62
+ renderPlayer?: (props: BroadcastPlayerRenderProps) => ReactNode;
63
+ }
64
+
65
+ /**
66
+ * The watch surface: the stream, who else is here, and the chat.
67
+ *
68
+ * Mounted ONLY behind a validated pass. Two playback paths, chosen by the
69
+ * broadcast itself: a WebRTC subscription when the broadcast carries a realtime
70
+ * config (sub-second, with a quality picker), and HLS otherwise.
71
+ */
72
+ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: BroadcastPlayerProps) {
73
+ const t = useForgeT();
74
+ const theme = useForgeTheme();
75
+ const { apiUrl, profileId } = useForge();
76
+ const sessionApi = useBroadcastSessionApi();
77
+
78
+ const [comments, setComments] = useState<BroadcastComment[]>([]);
79
+ const [comment, setComment] = useState("");
80
+ const [pinnedComment, setPinnedComment] = useState<BroadcastComment | null>(null);
81
+ const commentsContainerRef = useRef<HTMLDivElement>(null);
82
+ const socketRef = useRef<Socket | null>(null);
83
+
84
+ const videoRef = useRef<HTMLVideoElement>(null);
85
+ const audioRef = useRef<HTMLAudioElement>(null);
86
+ const videoContainerRef = useRef<HTMLDivElement>(null);
87
+ const [isAudioLoaded, setIsAudioLoaded] = useState(false);
88
+ const [isVideoLoaded, setIsVideoLoaded] = useState(false);
89
+ const [isPlaying, setIsPlaying] = useState(false);
90
+ const [showControls, setShowControls] = useState(false);
91
+ const [currentQuality, setCurrentQuality] = useState<string>("auto");
92
+ const [realtimeSessionId, setRealtimeSessionId] = useState<string | null>(null);
93
+ const isSubscribingRef = useRef(false);
94
+
95
+ const isRealtime = useMemo(
96
+ () => !!broadcast.realtimeConfig && Object.keys(broadcast.realtimeConfig).length > 0,
97
+ [broadcast.realtimeConfig],
98
+ );
99
+
100
+ const { data: audienceData } = useBroadcastAudience(broadcast.id, !broadcast.endedAt);
101
+ const { data: initialComments } = useBroadcastComments(broadcast.id);
102
+
103
+ // The backlog seeds the list once; everything after it arrives over the socket.
104
+ useEffect(() => {
105
+ if (initialComments) setComments(initialComments);
106
+ }, [initialComments]);
107
+
108
+ useEffect(() => {
109
+ if (comments.length === 0) return;
110
+ commentsContainerRef.current?.scrollTo({
111
+ top: commentsContainerRef.current.scrollHeight,
112
+ behavior: "smooth",
113
+ });
114
+ }, [comments]);
115
+
116
+ // ── WebRTC subscription ────────────────────────────────────────────────────
117
+ useEffect(() => {
118
+ const config = broadcast.realtimeConfig;
119
+ if (!isRealtime || !config) return;
120
+ if (isSubscribingRef.current) return;
121
+ isSubscribingRef.current = true;
122
+
123
+ const subscribe = async () => {
124
+ const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.cloudflare.com:3478" }] });
125
+ // The transceiver order is the contract with the server: video first,
126
+ // audio second. Swapping them silently hands the audio track to the video
127
+ // element and the viewer gets a black rectangle with sound.
128
+ pc.addTransceiver("video", { direction: "recvonly" });
129
+ pc.addTransceiver("audio", { direction: "recvonly" });
130
+
131
+ pc.ontrack = (event) => {
132
+ if (event.track.kind === "video" && videoRef.current && event.streams[0]) {
133
+ videoRef.current.srcObject = event.streams[0];
134
+ setIsVideoLoaded(true);
135
+ }
136
+ if (event.track.kind === "audio" && audioRef.current && event.streams[0]) {
137
+ const stream = new MediaStream();
138
+ stream.addTrack(event.track);
139
+ audioRef.current.srcObject = stream;
140
+ setIsAudioLoaded(true);
141
+ }
142
+ };
143
+
144
+ const offer = await pc.createOffer();
145
+ await pc.setLocalDescription(offer);
146
+ const local = pc.localDescription?.toJSON();
147
+
148
+ const answer = await sessionApi.subscribe({
149
+ sdp: local?.sdp,
150
+ type: local?.type,
151
+ trackIds: config.tracks.map((track) => track.trackName),
152
+ sessionId: config.sessionId,
153
+ });
154
+
155
+ setRealtimeSessionId(answer.sessionId);
156
+ await pc.setRemoteDescription(
157
+ new RTCSessionDescription({ sdp: answer.sessionDescription.sdp, type: "answer" }),
158
+ );
159
+ };
160
+
161
+ subscribe().catch(() => {
162
+ // Nothing useful to say to a viewer here: the spinner over the stage is
163
+ // already the honest report that no track arrived.
164
+ isSubscribingRef.current = false;
165
+ });
166
+ // `sessionApi` is rebuilt each render and is deliberately not a dependency:
167
+ // including it would re-negotiate the peer connection on every render.
168
+ // eslint-disable-next-line react-hooks/exhaustive-deps
169
+ }, [isRealtime, broadcast.realtimeConfig]);
170
+
171
+ // ── Chat socket ────────────────────────────────────────────────────────────
172
+ useEffect(() => {
173
+ if (!profileId || !broadcast.id || socketRef.current) return;
174
+
175
+ const socket = io(getForgeWebSocketUrl(apiUrl), {
176
+ auth: { profileId },
177
+ transports: ["websocket"],
178
+ });
179
+
180
+ socket.on("connect", () => {
181
+ socket.emit(SocketEvent.BroadcastJoin, { broadcastId: broadcast.id });
182
+ });
183
+
184
+ socket.on("message", (message: { event: string; data: unknown }) => {
185
+ if (message.event === SocketEvent.BroadcastChatMessage) {
186
+ const data = message.data as { broadcastId: string; message: BroadcastComment };
187
+ setComments((prev) => [...prev, data.message]);
188
+ return;
189
+ }
190
+ if (message.event === SocketEvent.BroadcastPinComment) {
191
+ const data = message.data as { broadcastId: string; comment: BroadcastComment | null };
192
+ setPinnedComment(data.comment);
193
+ }
194
+ });
195
+
196
+ socketRef.current = socket;
197
+ }, [apiUrl, profileId, broadcast.id]);
198
+
199
+ const sendComment = () => {
200
+ if (!profileId || !socketRef.current || !comment.trim()) return;
201
+ socketRef.current.emit(SocketEvent.BroadcastChatMessage, {
202
+ broadcastId: broadcast.id,
203
+ message: {
204
+ id: newId(),
205
+ name: broadcastPass.name || t("forge.broadcast_player.anonymous"),
206
+ profileId,
207
+ content: comment,
208
+ publishedAt: new Date().toISOString(),
209
+ channelProvider: BroadcastChannelProvider.Website,
210
+ },
211
+ });
212
+ setComment("");
213
+ };
214
+
215
+ const togglePlay = () => {
216
+ if (!isRealtime || !isAudioLoaded || !isVideoLoaded) return;
217
+ if (isPlaying) {
218
+ audioRef.current?.pause();
219
+ videoRef.current?.pause();
220
+ setIsPlaying(false);
221
+ return;
222
+ }
223
+ void audioRef.current?.play();
224
+ void videoRef.current?.play();
225
+ if (audioRef.current) audioRef.current.volume = 1;
226
+ setIsPlaying(true);
227
+ };
228
+
229
+ const toggleFullscreen = () => {
230
+ if (!videoContainerRef.current) return;
231
+ if (document.fullscreenElement) void document.exitFullscreen();
232
+ else void videoContainerRef.current.requestFullscreen();
233
+ };
234
+
235
+ const changeQuality = async (quality: string) => {
236
+ const config = broadcast.realtimeConfig;
237
+ if (!config) return;
238
+ setCurrentQuality(quality);
239
+ const videoTrack = config.tracks.find((track) => track.mid === "0");
240
+ try {
241
+ await sessionApi.switchQuality({
242
+ track: {
243
+ ...videoTrack,
244
+ sessionId: realtimeSessionId,
245
+ ...(quality !== "auto"
246
+ ? {
247
+ simulcast: {
248
+ preferredRid: quality,
249
+ priorityOrdering: "asciibetical",
250
+ ridNotAvailable: "asciibetical",
251
+ },
252
+ }
253
+ : {}),
254
+ },
255
+ sessionId: config.sessionId,
256
+ });
257
+ } catch {
258
+ // Falling back to whatever layer is already playing is better than
259
+ // stopping the stream to report a preference that did not take.
260
+ setCurrentQuality("auto");
261
+ }
262
+ };
263
+
264
+ const panel = {
265
+ backgroundColor: theme.colors.background,
266
+ borderRadius: theme.cornerRadius,
267
+ boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
268
+ border: `1px solid ${theme.colors.primary}30`,
269
+ } as const;
270
+
271
+ return (
272
+ <div
273
+ style={{
274
+ display: "flex",
275
+ flexWrap: "wrap",
276
+ gap: 32,
277
+ padding: 16,
278
+ maxWidth: 1700,
279
+ marginInline: "auto",
280
+ flex: 1,
281
+ color: theme.colors.text,
282
+ fontFamily: theme.fontFamily,
283
+ }}
284
+ >
285
+ <div style={{ flex: "1 1 520px", minWidth: 0, ...panel }}>
286
+ {!isRealtime && (
287
+ <div data-testid="broadcast-hls-stage" style={{ aspectRatio: "16/9" }}>
288
+ {renderPlayer ? (
289
+ renderPlayer({ src: broadcast.liveUrl })
290
+ ) : (
291
+ <video
292
+ src={broadcast.liveUrl}
293
+ controls
294
+ autoPlay
295
+ playsInline
296
+ style={{ width: "100%", height: "100%", background: "#000" }}
297
+ />
298
+ )}
299
+ </div>
300
+ )}
301
+
302
+ {isRealtime && (
303
+ <div
304
+ ref={videoContainerRef}
305
+ data-testid="broadcast-realtime-stage"
306
+ onClick={togglePlay}
307
+ onMouseEnter={() => setShowControls(true)}
308
+ onMouseLeave={() => setShowControls(false)}
309
+ style={{ aspectRatio: "16/9", position: "relative", background: "#000", cursor: "pointer" }}
310
+ >
311
+ <video ref={videoRef} playsInline style={{ width: "100%", height: "100%" }} />
312
+ <audio ref={audioRef} />
313
+
314
+ {(!isAudioLoaded || !isVideoLoaded) && (
315
+ <div
316
+ style={{
317
+ position: "absolute",
318
+ top: "50%",
319
+ left: "50%",
320
+ transform: "translate(-50%, -50%)",
321
+ pointerEvents: "none",
322
+ }}
323
+ >
324
+ <Loading />
325
+ </div>
326
+ )}
327
+
328
+ {isAudioLoaded && isVideoLoaded && !isPlaying && (
329
+ <div
330
+ style={{
331
+ position: "absolute",
332
+ inset: 0,
333
+ display: "flex",
334
+ alignItems: "center",
335
+ justifyContent: "center",
336
+ background: "rgba(0, 0, 0, 0.2)",
337
+ pointerEvents: "none",
338
+ }}
339
+ >
340
+ <Play size={64} color="#ffffff" fill="#ffffff" />
341
+ </div>
342
+ )}
343
+
344
+ {isAudioLoaded && isVideoLoaded && isPlaying && showControls && (
345
+ <div
346
+ style={{
347
+ position: "absolute",
348
+ top: "50%",
349
+ left: "50%",
350
+ transform: "translate(-50%, -50%)",
351
+ pointerEvents: "none",
352
+ opacity: 0.8,
353
+ }}
354
+ >
355
+ <Pause size={64} color="#ffffff" fill="#ffffff" />
356
+ </div>
357
+ )}
358
+
359
+ {isAudioLoaded && isVideoLoaded && (
360
+ <button
361
+ type="button"
362
+ onClick={(e) => {
363
+ e.stopPropagation();
364
+ toggleFullscreen();
365
+ }}
366
+ aria-label={t("forge.broadcast_player.fullscreen")}
367
+ style={{
368
+ position: "absolute",
369
+ bottom: 16,
370
+ right: 16,
371
+ padding: 8,
372
+ borderRadius: 8,
373
+ border: "none",
374
+ cursor: "pointer",
375
+ background: "rgba(0, 0, 0, 0.5)",
376
+ color: "#ffffff",
377
+ zIndex: 1,
378
+ }}
379
+ >
380
+ <Maximize size={20} />
381
+ </button>
382
+ )}
383
+ </div>
384
+ )}
385
+
386
+ <div
387
+ style={{
388
+ padding: 16,
389
+ display: "flex",
390
+ justifyContent: "space-between",
391
+ alignItems: "center",
392
+ gap: 16,
393
+ flexWrap: "wrap",
394
+ }}
395
+ >
396
+ <div>
397
+ <p style={{ fontSize: 22, fontWeight: 500 }}>{broadcast.title}</p>
398
+ <div style={{ display: "flex", alignItems: "center", gap: 24, marginTop: 8, fontSize: 14 }}>
399
+ <p>{t("forge.broadcast_player.watching_now", { count: audienceData?.count ?? 0 })}</p>
400
+ {broadcast.startedAt && (
401
+ <p>{t("forge.broadcast_player.started", { duration: timeAgo(broadcast.startedAt) })}</p>
402
+ )}
403
+ </div>
404
+ </div>
405
+
406
+ {isRealtime && (
407
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
408
+ <label htmlFor="broadcast-quality">{t("forge.broadcast_player.quality")}</label>
409
+ <select
410
+ id="broadcast-quality"
411
+ value={currentQuality}
412
+ onChange={(e) => void changeQuality(e.target.value)}
413
+ style={{
414
+ padding: 8,
415
+ borderRadius: theme.cornerRadius,
416
+ border: `1px solid ${theme.colors.primary}40`,
417
+ background: theme.colors.background,
418
+ color: theme.colors.text,
419
+ fontFamily: theme.fontFamily,
420
+ }}
421
+ >
422
+ {SIMULCAST_QUALITIES.map((quality) => (
423
+ <option key={quality.value} value={quality.value}>
424
+ {t(quality.label)}
425
+ </option>
426
+ ))}
427
+ </select>
428
+ </div>
429
+ )}
430
+ </div>
431
+ </div>
432
+
433
+ <div style={{ flex: "1 1 340px", maxWidth: 400, height: 600, position: "relative", overflow: "hidden", ...panel }}>
434
+ <p style={{ fontSize: 18, padding: 16, borderBottom: `1px solid ${theme.colors.primary}30` }}>
435
+ {t("forge.broadcast_player.chat")}
436
+ </p>
437
+
438
+ {pinnedComment && (
439
+ <div
440
+ data-testid="broadcast-pinned-comment"
441
+ style={{
442
+ display: "flex",
443
+ alignItems: "center",
444
+ gap: 8,
445
+ margin: "8px 16px 0",
446
+ padding: 8,
447
+ backgroundColor: `${theme.colors.primary}15`,
448
+ border: `1px solid ${theme.colors.primary}50`,
449
+ borderRadius: theme.cornerRadius,
450
+ }}
451
+ >
452
+ <Pin size={14} style={{ color: theme.colors.primary, flexShrink: 0 }} />
453
+ <div style={{ flex: 1, minWidth: 0 }}>
454
+ <span style={{ fontSize: 12, fontWeight: 500, color: theme.colors.primary }}>
455
+ {t("forge.broadcast_player.pinned")}
456
+ </span>
457
+ <span style={{ fontSize: 12, marginLeft: 8, opacity: 0.7 }}>{pinnedComment.name}</span>
458
+ <p style={{ fontSize: 14, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
459
+ {pinnedComment.content}
460
+ </p>
461
+ </div>
462
+ </div>
463
+ )}
464
+
465
+ <div ref={commentsContainerRef} style={{ overflowY: "auto", height: "100%", padding: "16px 16px 320px" }}>
466
+ {comments.map((item) => (
467
+ <div
468
+ key={item.id}
469
+ style={{
470
+ display: "flex",
471
+ flexDirection: "column",
472
+ gap: 8,
473
+ padding: 8,
474
+ marginBottom: 8,
475
+ border: `1px solid ${theme.colors.primary}30`,
476
+ borderRadius: theme.cornerRadius,
477
+ }}
478
+ >
479
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
480
+ <span style={{ fontSize: 14, opacity: 0.7 }}>{item.name}</span>
481
+ <span style={{ fontSize: 12, opacity: 0.6 }}>{timeAgo(item.publishedAt)}</span>
482
+ </div>
483
+ <div style={{ fontSize: 14 }}>{item.content}</div>
484
+ </div>
485
+ ))}
486
+ </div>
487
+
488
+ <div
489
+ style={{
490
+ position: "absolute",
491
+ bottom: 0,
492
+ left: 0,
493
+ right: 0,
494
+ padding: 16,
495
+ background: theme.colors.background,
496
+ display: "flex",
497
+ alignItems: "center",
498
+ gap: 8,
499
+ }}
500
+ >
501
+ <input
502
+ aria-label={t("forge.broadcast_player.message_label")}
503
+ value={comment}
504
+ onChange={(e) => setComment(e.target.value)}
505
+ onKeyDown={(e) => {
506
+ if (e.key !== "Enter") return;
507
+ e.preventDefault();
508
+ sendComment();
509
+ }}
510
+ style={{
511
+ flex: 1,
512
+ minWidth: 0,
513
+ padding: 10,
514
+ border: `1px solid ${theme.colors.primary}40`,
515
+ borderRadius: theme.cornerRadius,
516
+ background: theme.colors.background,
517
+ color: theme.colors.text,
518
+ fontFamily: theme.fontFamily,
519
+ }}
520
+ />
521
+ <button
522
+ type="button"
523
+ onClick={sendComment}
524
+ disabled={!comment.trim().length}
525
+ aria-label={t("forge.broadcast_player.send")}
526
+ style={buttonStyle(theme, { disabled: !comment.trim().length })}
527
+ >
528
+ <Send size={18} />
529
+ </button>
530
+ </div>
531
+ </div>
532
+ </div>
533
+ );
534
+ }
535
+
536
+ export default BroadcastPlayer;
@@ -0,0 +1,74 @@
1
+ import type { ILiveBroadcast } from "../../../types/models";
2
+ import { useEvent } from "../../../data/queries/useEvents";
3
+ import { useForgeT } from "../../../i18n";
4
+ import { useForgeTheme } from "../../theme/ForgeThemeProvider";
5
+ import { useAmountFormatter } from "../../format/useFormatCurrency";
6
+ import { minTicketPrice } from "../../headless/broadcast/broadcastState";
7
+ import { EventTickets } from "../EventTickets";
8
+
9
+ export interface BroadcastTicketPurchaseProps {
10
+ broadcast: ILiveBroadcast;
11
+ /** Format a numeric amount for display. Defaults to the site's currency formatter. */
12
+ formatAmount?: (amount: number) => string;
13
+ /** Stripe return URL for a PAID purchase (forwarded to the ticket flow). */
14
+ finalisePath?: (slug: string, orderId: string) => string;
15
+ /** Host-owned navigation on FREE completion (forwarded to the ticket flow). */
16
+ onComplete?: (info: { slug: string; orderId: string }) => void;
17
+ }
18
+
19
+ /**
20
+ * The way IN for a viewer who does not have a ticket: the linked event's real
21
+ * checkout, opened from the paywall.
22
+ *
23
+ * Without this the pass box is a dead end. Someone who followed a link to a paid
24
+ * broadcast, has no code, and is looking at a locked page has no route to buy
25
+ * one, which is the difference between a paywall and a closed door.
26
+ *
27
+ * Renders nothing until the linked event resolves, so a broadcast whose event
28
+ * has been deleted shows a ticket box and no misleading buy button.
29
+ */
30
+ export function BroadcastTicketPurchase({
31
+ broadcast,
32
+ formatAmount,
33
+ finalisePath,
34
+ onComplete,
35
+ }: BroadcastTicketPurchaseProps) {
36
+ const t = useForgeT();
37
+ const theme = useForgeTheme();
38
+ const fmt = useAmountFormatter(formatAmount);
39
+ const { data: event } = useEvent(broadcast.events?.[0]?.eventId);
40
+
41
+ if (!event) return null;
42
+
43
+ const from = minTicketPrice(broadcast);
44
+
45
+ return (
46
+ <div
47
+ data-testid="broadcast-buy-tickets"
48
+ style={{
49
+ width: "100%",
50
+ display: "flex",
51
+ flexDirection: "column",
52
+ gap: 8,
53
+ padding: 16,
54
+ alignItems: "center",
55
+ flexShrink: 0,
56
+ color: theme.colors.text,
57
+ border: `1px solid ${theme.colors.primary}80`,
58
+ borderRadius: theme.cornerRadius,
59
+ backgroundColor: theme.colors.background,
60
+ }}
61
+ >
62
+ <EventTickets
63
+ slug={event.slug}
64
+ triggerText={t("forge.broadcast_ticket_purchase.buy_tickets")}
65
+ formatAmount={fmt}
66
+ finalisePath={finalisePath}
67
+ onComplete={onComplete}
68
+ />
69
+ {from !== null && <p>{t("forge.broadcast_ticket_purchase.from_price", { price: fmt(from) })}</p>}
70
+ </div>
71
+ );
72
+ }
73
+
74
+ export default BroadcastTicketPurchase;