@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.
- package/package.json +11 -3
- package/src/_tests/publishedResolvability.spec.ts +184 -0
- package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
- package/src/_tests/workspaceAliases.ts +40 -0
- package/src/contexts/CartContext.tsx +17 -1
- package/src/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/CartContext.spec.tsx +36 -0
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useMyBookings.ts +18 -1
- package/src/i18n/_tests/translationKeys.spec.ts +15 -0
- package/src/i18n/de.json +97 -0
- package/src/i18n/en.json +97 -0
- package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
- package/src/ui/format/_tests/pwyw.spec.ts +65 -8
- package/src/ui/format/membershipPwyw.ts +164 -0
- package/src/ui/format/pwyw.ts +37 -0
- package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
- package/src/ui/headless/broadcast/broadcastState.ts +158 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
- package/src/ui/headless/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +14 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +61 -0
- package/src/ui/media/BookingCallScreen.tsx +33 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +633 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +931 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +227 -0
- package/src/ui/media/_tests/callState.spec.ts +499 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +182 -0
- package/src/ui/media/bookingWindow.ts +81 -0
- package/src/ui/media/callState.ts +360 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/styled/AccountDashboard.tsx +193 -4
- package/src/ui/styled/BroadcastWatch.tsx +107 -0
- package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
- package/src/ui/styled/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/LoginForm.tsx +10 -0
- package/src/ui/styled/MembershipCheckout.tsx +318 -45
- package/src/ui/styled/MembershipTiers.tsx +10 -3
- package/src/ui/styled/ResetPasswordForm.tsx +5 -0
- package/src/ui/styled/SignupForm.tsx +5 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +166 -0
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
- package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
- package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
- package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
- package/src/ui/styled/community/CommunityComposer.tsx +182 -3
- package/src/ui/styled/community/CommunityFeed.tsx +36 -51
- package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
- 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={
|
|
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,
|