@tribe-nest/forge 3.29.0 → 3.34.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 (58) hide show
  1. package/package.json +11 -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/contexts/CartContext.tsx +17 -1
  6. package/src/contexts/PublicAuthContext.tsx +34 -5
  7. package/src/contexts/_tests/CartContext.spec.tsx +36 -0
  8. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  9. package/src/data/queries/useBroadcasts.ts +151 -0
  10. package/src/data/queries/useMyBookings.ts +18 -1
  11. package/src/i18n/_tests/translationKeys.spec.ts +15 -0
  12. package/src/i18n/de.json +97 -0
  13. package/src/i18n/en.json +97 -0
  14. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  15. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  16. package/src/ui/format/membershipPwyw.ts +164 -0
  17. package/src/ui/format/pwyw.ts +37 -0
  18. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  19. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  20. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  21. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  22. package/src/ui/headless/index.ts +14 -0
  23. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  24. package/src/ui/index.ts +61 -0
  25. package/src/ui/media/BookingCallScreen.tsx +33 -0
  26. package/src/ui/media/CallHelpHint.tsx +87 -0
  27. package/src/ui/media/CallStage.tsx +633 -0
  28. package/src/ui/media/_tests/CallStage.spec.tsx +931 -0
  29. package/src/ui/media/_tests/bookingSession.spec.tsx +227 -0
  30. package/src/ui/media/_tests/callState.spec.ts +499 -0
  31. package/src/ui/media/_tests/fakeNode.ts +178 -0
  32. package/src/ui/media/bookingSession.tsx +182 -0
  33. package/src/ui/media/bookingWindow.ts +81 -0
  34. package/src/ui/media/callState.ts +360 -0
  35. package/src/ui/media/index.ts +135 -0
  36. package/src/ui/styled/AccountDashboard.tsx +193 -4
  37. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  38. package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
  39. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  40. package/src/ui/styled/LoginForm.tsx +10 -0
  41. package/src/ui/styled/MembershipCheckout.tsx +318 -45
  42. package/src/ui/styled/MembershipTiers.tsx +10 -3
  43. package/src/ui/styled/ResetPasswordForm.tsx +5 -0
  44. package/src/ui/styled/SignupForm.tsx +5 -0
  45. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +166 -0
  46. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  47. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  48. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  49. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  50. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  51. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  52. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  53. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  54. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  55. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  56. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  57. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
  58. package/src/ui/styled/forge-utilities.css +832 -0
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Forge UI - live calls.
3
+ *
4
+ * ## Where the ONE call UI lives
5
+ *
6
+ * Here. `CallStage` is built once in this package, over the hooks in
7
+ * `@tribe-nest/media-client/react`, and BOTH rendering surfaces render that one
8
+ * component: code websites import it from `@tribe-nest/forge/media`, and admin
9
+ * imports the same export and feeds it the dashboard's own theme tokens through
10
+ * `<ForgeThemeProvider>`. A second implementation would mean every fix to the
11
+ * ordering rules - device before transport, attach before resume, follow the
12
+ * active set - had to be made twice, and the second copy would be the one that
13
+ * drifted.
14
+ *
15
+ * The HOOKS are still pure re-exports, because the room logic belongs to the
16
+ * SDK and Forge has no business owning a second copy of it.
17
+ *
18
+ * ## Why a barrel at all, rather than telling sites to import the SDK
19
+ *
20
+ * The code agent builds sites from Forge's manifest, which is generated from
21
+ * these barrels. An export that is not in one does not exist as far as the
22
+ * agent is concerned, however well documented it is elsewhere. `./media` is
23
+ * registered in `ENTRY_SUBPATHS` in `apps/backend/src/utils/forgeManifest.ts`
24
+ * for exactly that reason.
25
+ *
26
+ * ## What a site actually needs to do
27
+ *
28
+ * Wrap the call in `<MediaRoomProvider>` and give it a `getCredentials`
29
+ * callback that fetches a fresh join ticket. It is a CALLBACK rather than a
30
+ * token because a ticket expires in minutes and a call lasts an hour: a token
31
+ * passed once means the first reconnect presents an expired one.
32
+ *
33
+ * ```tsx
34
+ * <MediaRoomProvider getCredentials={() => fetchJoinTicket(bookingId)}>
35
+ * <CallStage />
36
+ * </MediaRoomProvider>
37
+ * ```
38
+ *
39
+ * Inside, `useParticipants()`, `useRemoteTrack(producerId)` and
40
+ * `useLocalMedia()` are the whole surface. Nothing here decides what a
41
+ * participant is allowed to do: `useGrants()` is for RENDERING - so a button
42
+ * that the node would refuse is not offered - and the node enforces
43
+ * independently. A client-side grant check is a UI hint, never the decision.
44
+ */
45
+ export {
46
+ MediaRoomProvider,
47
+ useActiveSpeakers,
48
+ useGrants,
49
+ useLocalMedia,
50
+ useLocalPublications,
51
+ useMediaRoom,
52
+ useParticipants,
53
+ useRecording,
54
+ useRemoteTrack,
55
+ useRoomState,
56
+ // The producers a UI should RENDER: `useRoomState().producers` is the node's
57
+ // full announcement history and deliberately keeps producers the current
58
+ // subscribe rule bars, so that a barrier which narrows can later widen.
59
+ useVisibleProducers,
60
+ type LocalMediaControls,
61
+ type LocalSource,
62
+ type MediaRoomProviderProps,
63
+ } from "@tribe-nest/media-client/react";
64
+
65
+ export type {
66
+ ConnectionState,
67
+ DisconnectCause,
68
+ LocalPublication,
69
+ MediaRoom,
70
+ MediaTrack,
71
+ // `CallTileInput` is built from these, so a site laying out its own grid over
72
+ // `callTiles` needs them by name.
73
+ ProducerEntry,
74
+ RoomState,
75
+ } from "@tribe-nest/media-client";
76
+
77
+ export type { MediaGrants, Peer, SubscribeRule } from "@tribe-nest/media-protocol";
78
+
79
+ /**
80
+ * The screen itself, and the decisions behind it.
81
+ *
82
+ * `CallStage` is the whole call: participant tiles with names, the local
83
+ * preview, mic / camera / screenshare toggles, an active-speaker indication, a
84
+ * leave control and an honest connection state. Mount it inside a
85
+ * `<MediaRoomProvider>` (or `<BookingCallProvider>`) and behind a Join control,
86
+ * because the provider connects on mount.
87
+ *
88
+ * `callTiles`, `callStatus` and `canPublishSource` are exported alongside it for
89
+ * a surface that wants a different layout over the same decisions. They are
90
+ * pure and they are where the two failures worth being sure about live: a grid
91
+ * that keeps a tile for somebody with the camera off, and a connection state
92
+ * that separates "reconnecting" from "refused" rather than showing one spinner
93
+ * for both.
94
+ */
95
+ export { CallStage, type CallStageProps } from "./CallStage";
96
+ export { CallHelpHint, type CallHelpHintProps } from "./CallHelpHint";
97
+ export {
98
+ callHeadCount,
99
+ callStatus,
100
+ callTiles,
101
+ canPublishSource,
102
+ remoteAudioProducerIds,
103
+ type CallStatus,
104
+ type CallStatusInput,
105
+ type CallStatusTone,
106
+ type CallTile,
107
+ type CallTileInput,
108
+ type PublishSource,
109
+ } from "./callState";
110
+
111
+ /**
112
+ * Coaching sessions held on the platform's own media network.
113
+ *
114
+ * These are the ONE thing in this barrel that is not a re-export, because they
115
+ * are the only part that knows about a TribeNest endpoint: `BookingCallProvider`
116
+ * is `MediaRoomProvider` already wired to the booking credential endpoint, so a
117
+ * site never has to know what a join ticket is or how often to fetch one.
118
+ *
119
+ * They live in THIS barrel rather than in `./ui` for a mechanical reason: the
120
+ * code agent builds sites from Forge's manifest, and the manifest is generated
121
+ * from the registered entry points. An export outside one of them does not
122
+ * exist as far as the agent is concerned, however well documented it is.
123
+ */
124
+ export {
125
+ BOOKING_CALL_CLOSES_MINUTES_AFTER,
126
+ BOOKING_CALL_OPENS_MINUTES_BEFORE,
127
+ BookingCallProvider,
128
+ bookingCallWindow,
129
+ useBookingSessionCredentials,
130
+ type BookingCallProviderProps,
131
+ type BookingCallSubject,
132
+ type BookingCallWindow,
133
+ type BookingSessionCredentials,
134
+ type BookingSessionRole,
135
+ } from "./bookingSession";
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react";
1
+ import { lazy, Suspense, useEffect, useState } from "react";
2
2
  import {
3
3
  usePublicAuth,
4
4
  useUserOrders,
@@ -21,6 +21,7 @@ import {
21
21
  useCancelMembership,
22
22
  useMembershipAccess,
23
23
  useOpenBillingPortal,
24
+ useCommunitySpaces,
24
25
  useUpdateAccount,
25
26
  useChangePassword,
26
27
  useExportAccountData,
@@ -35,6 +36,7 @@ import { useThemeTokens } from "../theme/ForgeThemeProvider";
35
36
  import { useSiteConfig } from "../../data/queries/useWebsite";
36
37
  import { useAmountFormatter } from "../format/useFormatCurrency";
37
38
  import { Loading } from "./Loading";
39
+ import { bookingCallWindow } from "../media/bookingWindow";
38
40
  import { formatCountdown } from "./EventWaitlist";
39
41
  import { AddToCalendar } from "./AddToCalendar";
40
42
  import { WalletPassButtons } from "./WalletPassButtons";
@@ -85,6 +87,17 @@ export interface AccountDashboardProps {
85
87
  * this to its router (e.g. `/i/membership`).
86
88
  */
87
89
  onNavigateMembership?: () => void;
90
+ /**
91
+ * Navigate to the community spaces, the member home's Spaces tab (host maps
92
+ * this to its router, e.g. `/i/members/spaces`).
93
+ *
94
+ * Optional, and drawn only when the signed-in member has at least one space
95
+ * they can actually open: `/public/community/spaces` returns open spaces plus
96
+ * the spaces gated to a tier they hold, so an empty list is the server saying
97
+ * there is no door here. Without that check the account page would offer a
98
+ * member a room they get bounced out of, which is worse than no link at all.
99
+ */
100
+ onNavigateCommunity?: () => void;
88
101
  /**
89
102
  * Optional currency formatter override. Defaults to Forge's `useFormatCurrency`
90
103
  * (which honors the visitor's selected currency + tenant exchange rates), so the
@@ -150,6 +163,7 @@ interface TabContext {
150
163
  formatDate: (value: string | number | Date) => string;
151
164
  currency?: string;
152
165
  onNavigateMembership?: () => void;
166
+ onNavigateCommunity?: () => void;
153
167
  }
154
168
 
155
169
  // ---- page -------------------------------------------------------------------
@@ -163,6 +177,7 @@ export function AccountDashboard({
163
177
  tab,
164
178
  onTabChange,
165
179
  onNavigateMembership,
180
+ onNavigateCommunity,
166
181
  formatAmount,
167
182
  formatDate,
168
183
  currency,
@@ -184,6 +199,7 @@ export function AccountDashboard({
184
199
  // settlement currency (single source), unless the host overrides it.
185
200
  currency: currency ?? siteConfig?.currency,
186
201
  onNavigateMembership,
202
+ onNavigateCommunity,
187
203
  };
188
204
 
189
205
  if (!user) return <Loading fullPage />;
@@ -244,9 +260,23 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
244
260
  const { user } = usePublicAuth();
245
261
  const { t, card, button, ghostButton } = useCardStyles();
246
262
  const tr = useForgeT();
247
- const { formatAmount, formatDate, currency, onNavigateMembership } = ctx;
263
+ const { formatAmount, formatDate, currency, onNavigateMembership, onNavigateCommunity } = ctx;
248
264
  const cancelMembership = useCancelMembership();
265
+ // The way into the community, which the account page had no link to at all.
266
+ // The list is already access-filtered by the server (open spaces plus the
267
+ // ones gated to a tier this member holds), so its emptiness is the whole
268
+ // gate: no spaces, no link, and nobody is sent at a locked door.
269
+ const { data: communitySpaces } = useCommunitySpaces();
270
+ const canOpenCommunity = !!onNavigateCommunity && (communitySpaces?.length ?? 0) > 0;
249
271
  const [isCancelling, setIsCancelling] = useState(false);
272
+ /**
273
+ * Cancelling is destructive and irreversible from here, so it asks first.
274
+ * CLAUDE.md puts a destructive confirmation in the category that must stay
275
+ * VISIBLE rather than behind a `?`, and until now one click on Cancel simply
276
+ * ended the membership. The client app has always asked (`EditorModal` in
277
+ * `MembershipTab.tsx`); this surface was ported without it.
278
+ */
279
+ const [confirmingCancel, setConfirmingCancel] = useState(false);
250
280
  const [cancelError, setCancelError] = useState<string | null>(null);
251
281
 
252
282
  const membership = user?.membership;
@@ -348,7 +378,14 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
348
378
  <p style={{ opacity: 0.8 }}>{tr("forge.account_dashboard.membership_empty")}</p>
349
379
  )}
350
380
 
351
- <div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 24 }}>
381
+ <div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 24, flexWrap: "wrap" }}>
382
+ {/* Kept hard left, apart from the billing actions: this is the door to
383
+ what the membership is FOR, not another thing to do to the plan. */}
384
+ {canOpenCommunity && (
385
+ <button onClick={() => onNavigateCommunity?.()} style={{ ...ghostButton, marginRight: "auto" }}>
386
+ {tr("forge.account_dashboard.community_spaces")}
387
+ </button>
388
+ )}
352
389
  {message.showUpdatePayment && (
353
390
  <button onClick={onUpdatePayment} disabled={openBillingPortal.isPending} style={button}>
354
391
  {openBillingPortal.isPending
@@ -361,7 +398,7 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
361
398
  but not leave. Hidden once it is already scheduled ("Ending"), where
362
399
  the only honest label would be "Cancel again". */}
363
400
  {access.hasAccess && !access.cancelAtPeriodEnd && (
364
- <button onClick={onCancel} disabled={isCancelling} style={ghostButton}>
401
+ <button onClick={() => setConfirmingCancel(true)} disabled={isCancelling} style={ghostButton}>
365
402
  {isCancelling ? tr("forge.account_dashboard.cancelling") : tr("forge.account_dashboard.cancel")}
366
403
  </button>
367
404
  )}
@@ -371,6 +408,41 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
371
408
  : tr("forge.account_dashboard.membership_join")}
372
409
  </button>
373
410
  </div>
411
+
412
+ {/* Says WHICH of the two cancellations this is. A paid membership runs to
413
+ the end of the period already paid for; a free one stops now. A flat
414
+ "are you sure" tells a member who paid this month that they have lost
415
+ the rest of it, which is not true and reads as a threat. */}
416
+ {confirmingCancel && (
417
+ <div role="alertdialog" aria-modal="true" style={{ ...card, marginTop: 16 }}>
418
+ <p style={{ fontWeight: 700 }}>{tr("forge.account_dashboard.cancel_confirm_title")}</p>
419
+ <p style={{ fontSize: 14, opacity: 0.85, marginTop: 8 }}>
420
+ {membership?.subscriptionAmount
421
+ ? membership.endDate
422
+ ? tr("forge.account_dashboard.cancel_confirm_paid_until", { date: formatDate(membership.endDate) })
423
+ : tr("forge.account_dashboard.cancel_confirm_paid")
424
+ : tr("forge.account_dashboard.cancel_confirm_free")}
425
+ </p>
426
+ <div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 16 }}>
427
+ <button onClick={() => setConfirmingCancel(false)} disabled={isCancelling} style={ghostButton}>
428
+ {tr("forge.account_dashboard.cancel_confirm_keep")}
429
+ </button>
430
+ <button
431
+ onClick={() => {
432
+ // Closed FIRST, so the dialog cannot be double-submitted while
433
+ // the request is in flight. The row behind it already renders
434
+ // "Cancelling" from `isCancelling`.
435
+ setConfirmingCancel(false);
436
+ void onCancel();
437
+ }}
438
+ disabled={isCancelling}
439
+ style={button}
440
+ >
441
+ {tr("forge.account_dashboard.cancel_confirm_confirm")}
442
+ </button>
443
+ </div>
444
+ </div>
445
+ )}
374
446
  </div>
375
447
  );
376
448
  }
@@ -858,6 +930,24 @@ function BookingLocationLine({
858
930
  );
859
931
  }
860
932
 
933
+ /**
934
+ * A call on the platform's own media network. A LINE, and the way in is the
935
+ * `BookingCallEntry` control the row renders below it.
936
+ *
937
+ * There is no URL to render and there never will be: entry is a ticket minted
938
+ * per attempt and valid for minutes, so the Join control mints one on the
939
+ * click and mounts the call in place (`BookingCallEntry`, loaded lazily so a
940
+ * site pays for the SDK only when somebody presses it).
941
+ */
942
+ if (location.type === "video") {
943
+ return (
944
+ <div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
945
+ <span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_join_label")}{" "}</span>
946
+ {tr("forge.account_dashboard.booking_video_call")}
947
+ </div>
948
+ );
949
+ }
950
+
861
951
  return (
862
952
  <div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
863
953
  <span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_where_label")}{" "}</span>
@@ -984,6 +1074,105 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
984
1074
  </div>
985
1075
 
986
1076
  {picking && <RescheduleSlotPicker booking={booking} onDone={() => setPicking(false)} />}
1077
+
1078
+ {/* The way INTO a video session: a Join control while the room is open,
1079
+ the call itself once pressed. Nothing for any other kind of session. */}
1080
+ {!cancelled && <BookingCallEntry booking={booking} />}
1081
+ </div>
1082
+ );
1083
+ }
1084
+
1085
+ /**
1086
+ * The call UI, fetched on the click. `import()` splits it (and the media SDK
1087
+ * with mediasoup underneath) out of the `./ui` bundle, so a visitor who never
1088
+ * joins a call never downloads it. `bookingCallWindow` is deliberately in a
1089
+ * module with no such dependency, which is what lets the CONTROL be decided
1090
+ * here without loading the SDK.
1091
+ */
1092
+ const LazyBookingCallScreen = lazy(() => import("../media/BookingCallScreen"));
1093
+
1094
+ /**
1095
+ * The buyer's way into a coaching video call, on a code website.
1096
+ *
1097
+ * The room, the hooks, the credential endpoint and `BookingCallProvider` all
1098
+ * shipped before this control existed, and none of it was reachable from the
1099
+ * one place a client looks: the row for their session printed "Video call on
1100
+ * this site" and offered nothing to press. Admin's own help text told coaches
1101
+ * the client joins "from their account on your site", so this is where it has
1102
+ * to be. It renders the SAME `<CallStage>` the coach sees in admin.
1103
+ *
1104
+ * ## Why the call is mounted only after a click
1105
+ *
1106
+ * The provider connects on mount and closes on unmount. Rendering it for every
1107
+ * row in a list of sessions would open a media connection per row, against
1108
+ * rooms the server would then have to provision. So it lives behind Join, and
1109
+ * leaving unmounts it.
1110
+ *
1111
+ * ## Why the window ticks
1112
+ *
1113
+ * `bookingCallWindow` takes `now` as an argument rather than reading the clock,
1114
+ * so a portal left open across the opening minute has to be told. A minute is
1115
+ * fine: the server applies the same window and refuses a ticket outside it,
1116
+ * and its refusal is what `<CallStage>` shows rather than a pre-judgement here.
1117
+ */
1118
+ function BookingCallEntry({ booking }: { booking: MyBooking }) {
1119
+ const { button } = useCardStyles();
1120
+ const tr = useForgeT();
1121
+ const [now, setNow] = useState(() => new Date());
1122
+ const [joined, setJoined] = useState(false);
1123
+
1124
+ useEffect(() => {
1125
+ const timer = setInterval(() => setNow(new Date()), 30_000);
1126
+ return () => clearInterval(timer);
1127
+ }, []);
1128
+
1129
+ const call = bookingCallWindow(booking, now);
1130
+ if (!call.isVideo) return null;
1131
+
1132
+ if (joined) {
1133
+ // The provider's lifetime is exactly the call's: unmounting this subtree is
1134
+ // what closes the socket, stops the local tracks and puts the camera light
1135
+ // out. Leaving therefore goes through state, not through a `close()` that
1136
+ // leaves a live provider mounted to reconnect on its next render.
1137
+ return (
1138
+ <div style={{ marginTop: 12 }}>
1139
+ <Suspense fallback={<Loading />}>
1140
+ <LazyBookingCallScreen
1141
+ bookingId={booking.id}
1142
+ title={booking.coachingProductTitle}
1143
+ onLeave={() => setJoined(false)}
1144
+ />
1145
+ </Suspense>
1146
+ </div>
1147
+ );
1148
+ }
1149
+
1150
+ if (!call.isOpen) {
1151
+ // State the buyer has to act on, so it is content and stays visible: the
1152
+ // room is not open yet, and this is when to come back.
1153
+ return (
1154
+ <div style={{ fontSize: 13, opacity: 0.7, marginTop: 12 }}>
1155
+ {now < call.opensAt
1156
+ ? tr("forge.account_dashboard.booking_video_opens_at", {
1157
+ datetime: call.opensAt.toLocaleString(undefined, {
1158
+ weekday: "short",
1159
+ day: "numeric",
1160
+ month: "short",
1161
+ hour: "numeric",
1162
+ minute: "2-digit",
1163
+ timeZone: booking.coachingProductTimezone ?? undefined,
1164
+ }),
1165
+ })
1166
+ : tr("forge.account_dashboard.booking_video_closed")}
1167
+ </div>
1168
+ );
1169
+ }
1170
+
1171
+ return (
1172
+ <div style={{ marginTop: 12 }}>
1173
+ <button type="button" onClick={() => setJoined(true)} style={button}>
1174
+ {tr("forge.account_dashboard.booking_join_video")}
1175
+ </button>
987
1176
  </div>
988
1177
  );
989
1178
  }
@@ -0,0 +1,107 @@
1
+ import type { ReactNode } from "react";
2
+ import { X } from "lucide-react";
3
+ import { useForgeT } from "../../i18n";
4
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
5
+ import { useBroadcastWatch } from "../headless/broadcast/useBroadcastWatch";
6
+ import { Loading } from "./Loading";
7
+ import { BroadcastPassValidation } from "./broadcast/BroadcastPassValidation";
8
+ import { BroadcastTicketPurchase } from "./broadcast/BroadcastTicketPurchase";
9
+ import { BroadcastPlayer, type BroadcastPlayerRenderProps } from "./broadcast/BroadcastPlayer";
10
+ import { EndedBroadcast } from "./broadcast/EndedBroadcast";
11
+
12
+ export interface BroadcastWatchProps {
13
+ broadcastId: string;
14
+ /** Host-owned navigation. Falls back to a full page load. */
15
+ navigate?: (href: string) => void;
16
+ /** Where leaving lands. Default `/i/live`. */
17
+ livePath?: string;
18
+ /** How to play the HLS stream. See `BroadcastPlayer` for why this is a prop. */
19
+ renderPlayer?: (props: BroadcastPlayerRenderProps) => ReactNode;
20
+ /** Format a numeric amount for display (the "from ..." line on the paywall). */
21
+ formatAmount?: (amount: number) => string;
22
+ /** Stripe return URL for a PAID ticket purchase made from the paywall. */
23
+ finalisePath?: (slug: string, orderId: string) => string;
24
+ /** Host-owned navigation on FREE ticket completion. */
25
+ onComplete?: (info: { slug: string; orderId: string }) => void;
26
+ }
27
+
28
+ /**
29
+ * The watch page for one live broadcast.
30
+ *
31
+ * What is on screen is decided entirely by `useBroadcastWatch().screen`, which
32
+ * is computed by a pure function. The order those states are checked in is the
33
+ * whole safety property of this page: a broadcast that has ENDED shows the ended
34
+ * screen, a broadcast that is GATED shows the ticket box, and only a viewer past
35
+ * both gets a component that knows the playback URL.
36
+ */
37
+ export function BroadcastWatch({
38
+ broadcastId,
39
+ navigate,
40
+ livePath = "/i/live",
41
+ renderPlayer,
42
+ formatAmount,
43
+ finalisePath,
44
+ onComplete,
45
+ }: BroadcastWatchProps) {
46
+ const t = useForgeT();
47
+ const theme = useForgeTheme();
48
+ const watch = useBroadcastWatch(broadcastId);
49
+ const { broadcast, screen } = watch;
50
+
51
+ const leave = () => {
52
+ watch.leave();
53
+ if (navigate) navigate(livePath);
54
+ else if (typeof window !== "undefined") window.location.assign(livePath);
55
+ };
56
+
57
+ return (
58
+ <div
59
+ style={{
60
+ display: "flex",
61
+ flexDirection: "column",
62
+ flex: 1,
63
+ color: theme.colors.text,
64
+ fontFamily: theme.fontFamily,
65
+ }}
66
+ >
67
+ <div style={{ padding: "0 16px 8px" }}>
68
+ <button
69
+ type="button"
70
+ onClick={leave}
71
+ data-testid="broadcast-leave"
72
+ aria-label={t("forge.broadcast_watch.leave")}
73
+ style={{ background: "transparent", border: "none", padding: 0, cursor: "pointer" }}
74
+ >
75
+ <X size={28} color={theme.colors.primary} />
76
+ </button>
77
+ </div>
78
+
79
+ {screen.showLoading && <Loading fullPage />}
80
+
81
+ {screen.view === "error" && <div data-testid="broadcast-load-error">{t("forge.broadcast_watch.load_error")}</div>}
82
+
83
+ {screen.view === "ended" && broadcast && <EndedBroadcast broadcast={broadcast} />}
84
+
85
+ {screen.view === "player" && broadcast && watch.pass && (
86
+ <BroadcastPlayer broadcast={broadcast} broadcastPass={watch.pass} renderPlayer={renderPlayer} />
87
+ )}
88
+
89
+ {screen.view === "pass" && broadcast && (
90
+ <BroadcastPassValidation
91
+ broadcast={broadcast}
92
+ onValidate={(ticketCode) => watch.validate(ticketCode).catch(() => undefined)}
93
+ isValidating={watch.isValidating}
94
+ error={watch.error}
95
+ buyTickets={
96
+ <BroadcastTicketPurchase
97
+ broadcast={broadcast}
98
+ formatAmount={formatAmount}
99
+ finalisePath={finalisePath}
100
+ onComplete={onComplete}
101
+ />
102
+ }
103
+ />
104
+ )}
105
+ </div>
106
+ );
107
+ }
@@ -21,6 +21,11 @@ export function ForgotPasswordForm({ loginHref }: ForgotPasswordFormProps) {
21
21
  const [isSuccess, setIsSuccess] = useState(false);
22
22
 
23
23
  const card: React.CSSProperties = {
24
+ // See `LoginForm`. The host layout is a COLUMN flex container, and an auto
25
+ // cross-axis margin disables `align-items: stretch`, so `maxWidth` alone
26
+ // leaves the card shrink-to-fit. The cap is unchanged: this fills the
27
+ // container UP TO 420 and no further.
28
+ width: "100%",
24
29
  maxWidth: 420,
25
30
  margin: "40px auto 0",
26
31
  padding: 24,