@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.
- package/package.json +6 -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/client/_tests/localeHeader.spec.tsx +98 -0
- package/src/client/activeLocale.ts +34 -0
- package/src/client/createForgeClient.ts +13 -0
- package/src/contexts/CartContext.tsx +76 -0
- package/src/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/CartContext.spec.tsx +210 -0
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- package/src/data/queries/useMyBookings.ts +9 -1
- package/src/data/queries/useWebsite.ts +7 -0
- package/src/i18n/CONVENTION.md +184 -0
- package/src/i18n/_tests/translationKeys.spec.ts +153 -0
- package/src/i18n/de.json +1019 -0
- package/src/i18n/en.json +1019 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +21 -0
- package/src/provider/ForgeAppProvider.tsx +2 -1
- package/src/provider/SiteConfigProvider.tsx +11 -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/checkout/_tests/cartResume.spec.ts +291 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
- package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
- package/src/ui/headless/checkout/useCartResume.ts +340 -0
- package/src/ui/headless/checkout/useCheckout.ts +9 -2
- package/src/ui/headless/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +32 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +38 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +542 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
- package/src/ui/media/_tests/callState.spec.ts +452 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +194 -0
- package/src/ui/media/callState.ts +341 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/shell/TribeNestApp.tsx +36 -27
- package/src/ui/styled/AccountDashboard.tsx +289 -96
- package/src/ui/styled/AiAgentWidget.tsx +10 -6
- package/src/ui/styled/AudioPlayer.tsx +14 -6
- package/src/ui/styled/BlogCategory.tsx +4 -2
- package/src/ui/styled/BlogComments.tsx +27 -20
- package/src/ui/styled/BlogList.tsx +5 -3
- package/src/ui/styled/BlogPost.tsx +14 -11
- package/src/ui/styled/BroadcastWatch.tsx +107 -0
- package/src/ui/styled/BundleConfirmation.tsx +32 -23
- package/src/ui/styled/CancellationTerms.tsx +3 -1
- package/src/ui/styled/Cart.tsx +32 -14
- package/src/ui/styled/ChatRoom.tsx +65 -20
- package/src/ui/styled/Checkout.tsx +151 -89
- package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
- package/src/ui/styled/CoachingBooking.tsx +71 -44
- package/src/ui/styled/CoachingConfirmation.tsx +56 -19
- package/src/ui/styled/CoachingDetail.tsx +15 -11
- package/src/ui/styled/CohortPage.tsx +10 -8
- package/src/ui/styled/ConfirmSubscription.tsx +14 -8
- package/src/ui/styled/ContactForm.tsx +31 -12
- package/src/ui/styled/CookieConsent.tsx +19 -15
- package/src/ui/styled/CourseAccess.tsx +54 -47
- package/src/ui/styled/CourseCheckout.tsx +56 -38
- package/src/ui/styled/CourseConfirmation.tsx +39 -17
- package/src/ui/styled/CourseDetail.tsx +21 -15
- package/src/ui/styled/CoursesGrid.tsx +4 -2
- package/src/ui/styled/CurrencySwitcher.tsx +4 -1
- package/src/ui/styled/DiscountCode.tsx +22 -18
- package/src/ui/styled/DonationButton.tsx +52 -17
- package/src/ui/styled/DonationPage.tsx +4 -4
- package/src/ui/styled/EmailListForm.tsx +22 -9
- package/src/ui/styled/EventConfirmation.tsx +37 -14
- package/src/ui/styled/EventCountdown.tsx +10 -6
- package/src/ui/styled/EventDetail.tsx +8 -6
- package/src/ui/styled/EventSeriesDetail.tsx +18 -13
- package/src/ui/styled/EventTickets.tsx +232 -108
- package/src/ui/styled/EventWaitlist.tsx +42 -33
- package/src/ui/styled/EventsList.tsx +15 -13
- package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
- package/src/ui/styled/FormRenderer.tsx +12 -7
- package/src/ui/styled/InstallBanner.tsx +21 -18
- package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
- package/src/ui/styled/InvoicePayment.tsx +37 -16
- package/src/ui/styled/LeadMagnet.tsx +9 -7
- package/src/ui/styled/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/Loading.tsx +7 -1
- package/src/ui/styled/LoginForm.tsx +32 -14
- package/src/ui/styled/MembershipCheckout.tsx +341 -49
- package/src/ui/styled/MembershipTiers.tsx +34 -18
- package/src/ui/styled/OfferButton.tsx +33 -13
- package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
- package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
- package/src/ui/styled/PodcastEpisode.tsx +20 -9
- package/src/ui/styled/PodcastList.tsx +9 -3
- package/src/ui/styled/PodcastShow.tsx +29 -7
- package/src/ui/styled/PostsFeed.tsx +10 -8
- package/src/ui/styled/PresaleCode.tsx +8 -11
- package/src/ui/styled/ProductBrowseNav.tsx +21 -11
- package/src/ui/styled/ProductDetail.tsx +58 -28
- package/src/ui/styled/ReactionBar.tsx +4 -2
- package/src/ui/styled/ReplayList.tsx +24 -13
- package/src/ui/styled/ResetPasswordForm.tsx +32 -15
- package/src/ui/styled/ResumeCart.tsx +289 -0
- package/src/ui/styled/ReviewForm.tsx +52 -58
- package/src/ui/styled/SignupForm.tsx +34 -16
- package/src/ui/styled/TicketTransfer.tsx +55 -54
- package/src/ui/styled/UserMenu.tsx +16 -14
- 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/_tests/ResumeCart.spec.tsx +183 -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
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import type { ILiveBroadcast, IBroadcastPass } from "../../../../types/models";
|
|
3
|
+
import {
|
|
4
|
+
broadcastListStatus,
|
|
5
|
+
broadcastSessionKey,
|
|
6
|
+
hasBroadcastEnded,
|
|
7
|
+
isPassValidated,
|
|
8
|
+
minTicketPrice,
|
|
9
|
+
needsPassValidation,
|
|
10
|
+
selectBroadcastScreen,
|
|
11
|
+
} from "../broadcastState";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The rules a live-broadcast page runs on, checked without a browser.
|
|
15
|
+
*
|
|
16
|
+
* The player is deliberately not tested here: it is a video element, a WebRTC
|
|
17
|
+
* negotiation and a socket, and none of those tell you whether the right person
|
|
18
|
+
* is looking at them. These functions do.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const NOW = new Date("2026-08-18T12:00:00.000Z");
|
|
22
|
+
|
|
23
|
+
function broadcast(overrides: Partial<ILiveBroadcast> = {}): ILiveBroadcast {
|
|
24
|
+
return {
|
|
25
|
+
id: "b1",
|
|
26
|
+
title: "Sunday Session",
|
|
27
|
+
createdAt: "2026-08-01T00:00:00.000Z",
|
|
28
|
+
updatedAt: "2026-08-01T00:00:00.000Z",
|
|
29
|
+
profileId: "p1",
|
|
30
|
+
streamTemplateId: "st1",
|
|
31
|
+
egressId: "e1",
|
|
32
|
+
events: [],
|
|
33
|
+
event: { id: "ev1", title: "Sunday Session", description: "", isPaid: true },
|
|
34
|
+
...overrides,
|
|
35
|
+
} as ILiveBroadcast;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const gate = (price = 25) =>
|
|
39
|
+
[
|
|
40
|
+
{
|
|
41
|
+
eventId: "ev1",
|
|
42
|
+
eventTitle: "Sunday Session",
|
|
43
|
+
eventTickets: [{ price } as unknown as ILiveBroadcast["events"][number]["eventTickets"][number]],
|
|
44
|
+
},
|
|
45
|
+
] as ILiveBroadcast["events"];
|
|
46
|
+
|
|
47
|
+
const pass: IBroadcastPass = { sessionId: "s1", name: "Ada", email: "ada@example.com" };
|
|
48
|
+
|
|
49
|
+
describe("broadcastListStatus", () => {
|
|
50
|
+
it("calls a started, unfinished broadcast LIVE", () => {
|
|
51
|
+
const status = broadcastListStatus(broadcast({ startedAt: "2026-08-18T11:00:00.000Z" }), NOW);
|
|
52
|
+
expect(status.text).toBe("LIVE");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("REGRESSION: LIVE wins over a start DATE that has not arrived", () => {
|
|
56
|
+
// A host who goes on early has `startedAt` set and a `startDate` still in
|
|
57
|
+
// the future. Reading the schedule first would badge a broadcast that is
|
|
58
|
+
// ON AIR as UPCOMING, and everyone waits through the show they came for.
|
|
59
|
+
const status = broadcastListStatus(
|
|
60
|
+
broadcast({ startedAt: "2026-08-18T11:00:00.000Z", startDate: "2026-08-18T20:00:00.000Z" }),
|
|
61
|
+
NOW,
|
|
62
|
+
);
|
|
63
|
+
expect(status.text).toBe("LIVE");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("calls a future start date UPCOMING", () => {
|
|
67
|
+
expect(broadcastListStatus(broadcast({ startDate: "2026-08-18T20:00:00.000Z" }), NOW).text).toBe("UPCOMING");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("calls a finished broadcast ENDED even though it started", () => {
|
|
71
|
+
const status = broadcastListStatus(
|
|
72
|
+
broadcast({ startedAt: "2026-08-18T10:00:00.000Z", endedAt: "2026-08-18T11:00:00.000Z" }),
|
|
73
|
+
NOW,
|
|
74
|
+
);
|
|
75
|
+
expect(status.text).toBe("ENDED");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("calls a past start date with no start at all ENDED", () => {
|
|
79
|
+
expect(broadcastListStatus(broadcast({ startDate: "2026-08-17T20:00:00.000Z" }), NOW).text).toBe("ENDED");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("gives the three states three different badge colours", () => {
|
|
83
|
+
const live = broadcastListStatus(broadcast({ startedAt: "2026-08-18T11:00:00.000Z" }), NOW);
|
|
84
|
+
const upcoming = broadcastListStatus(broadcast({ startDate: "2026-08-18T20:00:00.000Z" }), NOW);
|
|
85
|
+
const ended = broadcastListStatus(broadcast(), NOW);
|
|
86
|
+
expect(new Set([live.background, upcoming.background, ended.background]).size).toBe(3);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("needsPassValidation", () => {
|
|
91
|
+
it("is the linked event, and nothing else", () => {
|
|
92
|
+
expect(needsPassValidation(broadcast({ events: gate() }))).toBe(true);
|
|
93
|
+
expect(needsPassValidation(broadcast({ events: [] }))).toBe(false);
|
|
94
|
+
expect(needsPassValidation(undefined)).toBe(false);
|
|
95
|
+
expect(needsPassValidation(null)).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("hasBroadcastEnded", () => {
|
|
100
|
+
it("ends on the POLLED copy, which is the only thing that notices a host hanging up", () => {
|
|
101
|
+
const opened = broadcast();
|
|
102
|
+
const polled = broadcast({ endedAt: "2026-08-18T11:59:00.000Z" });
|
|
103
|
+
expect(hasBroadcastEnded(opened, polled)).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("ends on the opened copy for a viewer who arrives late", () => {
|
|
107
|
+
expect(hasBroadcastEnded(broadcast({ endedAt: "2026-08-18T11:00:00.000Z" }), undefined)).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("is not ended while neither copy says so", () => {
|
|
111
|
+
expect(hasBroadcastEnded(broadcast({ startedAt: "2026-08-18T11:00:00.000Z" }), broadcast())).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("isPassValidated", () => {
|
|
116
|
+
it("REGRESSION: a gated broadcast without a validated pass is NOT open", () => {
|
|
117
|
+
// This single expression is the paywall. False here is a paid stream
|
|
118
|
+
// playing for anybody who has the URL.
|
|
119
|
+
expect(isPassValidated(broadcast({ events: gate() }), false)).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("opens a gated broadcast once the pass is validated", () => {
|
|
123
|
+
expect(isPassValidated(broadcast({ events: gate() }), true)).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("lets everyone into a broadcast with no linked event", () => {
|
|
127
|
+
expect(isPassValidated(broadcast({ events: [] }), false)).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("minTicketPrice", () => {
|
|
132
|
+
it("takes the cheapest tier on the linked event", () => {
|
|
133
|
+
const events = [
|
|
134
|
+
{
|
|
135
|
+
eventId: "ev1",
|
|
136
|
+
eventTitle: "Sunday Session",
|
|
137
|
+
eventTickets: [{ price: 40 }, { price: 15 }, { price: 60 }],
|
|
138
|
+
},
|
|
139
|
+
] as unknown as ILiveBroadcast["events"];
|
|
140
|
+
expect(minTicketPrice(broadcast({ events }))).toBe(15);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("REGRESSION: says nothing rather than 'From Infinity' when the event lists no tiers", () => {
|
|
144
|
+
const events = [{ eventId: "ev1", eventTitle: "x", eventTickets: [] }] as unknown as ILiveBroadcast["events"];
|
|
145
|
+
expect(minTicketPrice(broadcast({ events }))).toBeNull();
|
|
146
|
+
expect(minTicketPrice(broadcast({ events: [] }))).toBeNull();
|
|
147
|
+
expect(minTicketPrice(undefined)).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("broadcastSessionKey", () => {
|
|
152
|
+
it("scopes the stored session to ONE broadcast", () => {
|
|
153
|
+
// A shared key would carry a ticket for last week's show into this week's.
|
|
154
|
+
expect(broadcastSessionKey("b1")).toBe("broadcast_b1_session_id");
|
|
155
|
+
expect(broadcastSessionKey("b1")).not.toBe(broadcastSessionKey("b2"));
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("selectBroadcastScreen", () => {
|
|
160
|
+
const base = {
|
|
161
|
+
isBroadcastLoading: false,
|
|
162
|
+
isSessionLoading: false,
|
|
163
|
+
hasValidPass: false,
|
|
164
|
+
pass: null,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
it("decides nothing until a stored session has been re-checked", () => {
|
|
168
|
+
const screen = selectBroadcastScreen({
|
|
169
|
+
...base,
|
|
170
|
+
isSessionLoading: true,
|
|
171
|
+
broadcast: broadcast({ events: gate() }),
|
|
172
|
+
});
|
|
173
|
+
expect(screen).toEqual({ showLoading: true, view: "none" });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("reports a broadcast that could not be read", () => {
|
|
177
|
+
expect(selectBroadcastScreen({ ...base, error: new Error("boom") }).view).toBe("error");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("REGRESSION: a GATED broadcast shows the ticket box, never the player", () => {
|
|
181
|
+
const screen = selectBroadcastScreen({ ...base, broadcast: broadcast({ events: gate() }) });
|
|
182
|
+
expect(screen.view).toBe("pass");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("plays a gated broadcast once a pass is held", () => {
|
|
186
|
+
const screen = selectBroadcastScreen({
|
|
187
|
+
...base,
|
|
188
|
+
broadcast: broadcast({ events: gate(), startedAt: "2026-08-18T11:00:00.000Z" }),
|
|
189
|
+
hasValidPass: true,
|
|
190
|
+
pass,
|
|
191
|
+
});
|
|
192
|
+
expect(screen.view).toBe("player");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("REGRESSION: an ENDED gated broadcast shows the ended screen, not a ticket box", () => {
|
|
196
|
+
// Asking somebody for a ticket to a stream that finished is both useless
|
|
197
|
+
// and, for anyone who then buys one, a refund.
|
|
198
|
+
const screen = selectBroadcastScreen({
|
|
199
|
+
...base,
|
|
200
|
+
broadcast: broadcast({ events: gate() }),
|
|
201
|
+
polled: broadcast({ endedAt: "2026-08-18T11:59:00.000Z" }),
|
|
202
|
+
});
|
|
203
|
+
expect(screen.view).toBe("ended");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("shows the ended screen to a pass holder too", () => {
|
|
207
|
+
const screen = selectBroadcastScreen({
|
|
208
|
+
...base,
|
|
209
|
+
broadcast: broadcast({ events: gate(), endedAt: "2026-08-18T11:00:00.000Z" }),
|
|
210
|
+
hasValidPass: true,
|
|
211
|
+
pass,
|
|
212
|
+
});
|
|
213
|
+
expect(screen.view).toBe("ended");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("keeps the spinner alongside the screen while the broadcast is still loading", () => {
|
|
217
|
+
const screen = selectBroadcastScreen({
|
|
218
|
+
...base,
|
|
219
|
+
isBroadcastLoading: true,
|
|
220
|
+
broadcast: broadcast({ events: gate() }),
|
|
221
|
+
});
|
|
222
|
+
expect(screen).toEqual({ showLoading: true, view: "pass" });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("draws nothing for an OPEN broadcast until a pass exists, matching apps/client", () => {
|
|
226
|
+
// Pinned, not endorsed. The player needs a pass to sign chat messages with,
|
|
227
|
+
// so a broadcast with no linked event and no stored session renders blank on
|
|
228
|
+
// both stacks. Reported alongside this port.
|
|
229
|
+
const screen = selectBroadcastScreen({
|
|
230
|
+
...base,
|
|
231
|
+
broadcast: broadcast({ events: [], startedAt: "2026-08-18T11:00:00.000Z" }),
|
|
232
|
+
});
|
|
233
|
+
expect(screen.view).toBe("none");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { ILiveBroadcast, IBroadcastPass } from "../../../types/models";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The decisions a live-broadcast page makes, with no React and no network in
|
|
5
|
+
* them: which badge a broadcast wears in the list, whether the page a viewer
|
|
6
|
+
* opened is still on, and whether they are allowed past the paywall.
|
|
7
|
+
*
|
|
8
|
+
* They are pure so both rendering stacks (apps/client and a Forge code site)
|
|
9
|
+
* can be checked against the same rules. Every one of these is a rule a wrong
|
|
10
|
+
* answer turns into either a paid broadcast given away for free, or a viewer
|
|
11
|
+
* staring at a player for a stream that ended twenty minutes ago.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The three states a broadcast can be in, as the list badge names them. */
|
|
15
|
+
export type BroadcastListStatusText = "LIVE" | "UPCOMING" | "ENDED";
|
|
16
|
+
|
|
17
|
+
export interface BroadcastListStatus {
|
|
18
|
+
text: BroadcastListStatusText;
|
|
19
|
+
/** Badge background. Status colour, deliberately not a theme token: see below. */
|
|
20
|
+
background: string;
|
|
21
|
+
/** Badge foreground. */
|
|
22
|
+
color: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Status colours are literal rather than theme tokens on purpose. A LIVE badge
|
|
27
|
+
* is red on every streaming surface a viewer has ever used, and re-tinting it to
|
|
28
|
+
* a tenant's brand primary would make "on air" and "ended" the same colour on
|
|
29
|
+
* any site whose brand happens to be grey. Everything else on these pages
|
|
30
|
+
* (surfaces, borders, text, radius) reads from the theme.
|
|
31
|
+
*/
|
|
32
|
+
const STATUS_COLORS: Record<BroadcastListStatusText, { background: string; color: string }> = {
|
|
33
|
+
LIVE: { background: "#ef4444", color: "#ffffff" },
|
|
34
|
+
UPCOMING: { background: "#3b82f6", color: "#ffffff" },
|
|
35
|
+
ENDED: { background: "#6b7280", color: "#ffffff" },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Which badge a broadcast wears in the "what is on now" list.
|
|
40
|
+
*
|
|
41
|
+
* There is no status column on the row: the lifecycle is derived from the
|
|
42
|
+
* timestamps. Started and not ended is LIVE, a start date still in the future is
|
|
43
|
+
* UPCOMING, and everything else has been and gone.
|
|
44
|
+
*/
|
|
45
|
+
export function broadcastListStatus(broadcast: ILiveBroadcast, now: Date = new Date()): BroadcastListStatus {
|
|
46
|
+
if (broadcast.startedAt && !broadcast.endedAt) {
|
|
47
|
+
return { text: "LIVE", ...STATUS_COLORS.LIVE };
|
|
48
|
+
}
|
|
49
|
+
if (broadcast.startDate && new Date(broadcast.startDate) > now) {
|
|
50
|
+
return { text: "UPCOMING", ...STATUS_COLORS.UPCOMING };
|
|
51
|
+
}
|
|
52
|
+
return { text: "ENDED", ...STATUS_COLORS.ENDED };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Does this broadcast sit behind a ticket?
|
|
57
|
+
*
|
|
58
|
+
* A linked event IS the paywall. A broadcast with no linked event is open, and a
|
|
59
|
+
* broadcast with one is not, so this single expression is what stands between a
|
|
60
|
+
* paid stream and anyone who knows the URL.
|
|
61
|
+
*/
|
|
62
|
+
export function needsPassValidation(broadcast?: ILiveBroadcast | null): boolean {
|
|
63
|
+
return !!broadcast?.events?.length;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Has it ended?
|
|
68
|
+
*
|
|
69
|
+
* Two sources, because the page holds the broadcast it OPENED with and polls a
|
|
70
|
+
* second copy. The poll is the only thing that notices the host hanging up while
|
|
71
|
+
* somebody is watching, so an end seen in either copy ends the page.
|
|
72
|
+
*/
|
|
73
|
+
export function hasBroadcastEnded(broadcast?: ILiveBroadcast | null, polled?: ILiveBroadcast | null): boolean {
|
|
74
|
+
return !!polled?.endedAt || !!broadcast?.endedAt;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* May this viewer through?
|
|
79
|
+
*
|
|
80
|
+
* An open broadcast passes everyone. A gated one passes only a viewer holding a
|
|
81
|
+
* validated pass. Note the direction of the default: when there is no gate the
|
|
82
|
+
* answer is yes, so a bug that loses the events array opens the paywall rather
|
|
83
|
+
* than closing it. That is why `needsPassValidation` reads the broadcast and
|
|
84
|
+
* never a flag the caller passes in.
|
|
85
|
+
*/
|
|
86
|
+
export function isPassValidated(broadcast: ILiveBroadcast | null | undefined, hasValidPass: boolean): boolean {
|
|
87
|
+
return needsPassValidation(broadcast) ? hasValidPass : true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Where a validated session id is parked so a refresh does not re-ask for the ticket. */
|
|
91
|
+
export function broadcastSessionKey(broadcastId: string): string {
|
|
92
|
+
return `broadcast_${broadcastId}_session_id`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The cheapest ticket on the linked event, for the "from ..." line beside the
|
|
97
|
+
* buy button. `null` when the event lists no tickets at all, so the caller
|
|
98
|
+
* renders nothing rather than the word "Infinity".
|
|
99
|
+
*/
|
|
100
|
+
export function minTicketPrice(broadcast?: ILiveBroadcast | null): number | null {
|
|
101
|
+
const tickets = broadcast?.events?.[0]?.eventTickets;
|
|
102
|
+
if (!tickets || tickets.length === 0) return null;
|
|
103
|
+
const min = tickets.reduce((lowest, ticket) => Math.min(lowest, ticket.price), Infinity);
|
|
104
|
+
return Number.isFinite(min) ? min : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** What the watch page puts on screen. */
|
|
108
|
+
export type BroadcastView = "none" | "error" | "ended" | "player" | "pass";
|
|
109
|
+
|
|
110
|
+
export interface BroadcastScreen {
|
|
111
|
+
/** The spinner. It can sit ALONGSIDE a view, exactly as the client renders it. */
|
|
112
|
+
showLoading: boolean;
|
|
113
|
+
view: BroadcastView;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface BroadcastScreenInput {
|
|
117
|
+
/** The broadcast the page opened with. */
|
|
118
|
+
broadcast?: ILiveBroadcast | null;
|
|
119
|
+
/** The polled copy, which is allowed to say only one thing: that it ended. */
|
|
120
|
+
polled?: ILiveBroadcast | null;
|
|
121
|
+
isBroadcastLoading: boolean;
|
|
122
|
+
/** Re-opening a stored session. Nothing renders until this settles. */
|
|
123
|
+
isSessionLoading: boolean;
|
|
124
|
+
error?: unknown;
|
|
125
|
+
hasValidPass: boolean;
|
|
126
|
+
pass?: IBroadcastPass | null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The whole render decision for the watch page, in one place.
|
|
131
|
+
*
|
|
132
|
+
* Order matters and is not arbitrary. ENDED is checked before the pass, so a
|
|
133
|
+
* viewer who arrives after the stream is over gets the "it ended" screen instead
|
|
134
|
+
* of being asked for a ticket to nothing. The pass gate is checked before the
|
|
135
|
+
* player, so no frame of a gated broadcast is ever mounted for a viewer who has
|
|
136
|
+
* not presented one.
|
|
137
|
+
*/
|
|
138
|
+
export function selectBroadcastScreen(input: BroadcastScreenInput): BroadcastScreen {
|
|
139
|
+
const { broadcast, polled, isBroadcastLoading, isSessionLoading, error, hasValidPass, pass } = input;
|
|
140
|
+
const showLoading = isBroadcastLoading || isSessionLoading;
|
|
141
|
+
|
|
142
|
+
// Nothing at all is decided until the stored session has been re-checked:
|
|
143
|
+
// deciding early would flash the ticket box at a viewer who already has one.
|
|
144
|
+
if (isSessionLoading) return { showLoading: true, view: "none" };
|
|
145
|
+
|
|
146
|
+
if (error) return { showLoading, view: "error" };
|
|
147
|
+
if (!broadcast) return { showLoading, view: "none" };
|
|
148
|
+
|
|
149
|
+
if (hasBroadcastEnded(broadcast, polled)) return { showLoading, view: "ended" };
|
|
150
|
+
|
|
151
|
+
if (!isPassValidated(broadcast, hasValidPass)) return { showLoading, view: "pass" };
|
|
152
|
+
|
|
153
|
+
// A pass is what names the viewer in chat, so the player waits for one even on
|
|
154
|
+
// an open broadcast. Matches apps/client exactly.
|
|
155
|
+
if (pass) return { showLoading, view: "player" };
|
|
156
|
+
|
|
157
|
+
return { showLoading, view: "none" };
|
|
158
|
+
}
|
|
@@ -1,43 +1,143 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import type { IBroadcastPass } from "../../../types/models";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
useLiveBroadcast,
|
|
5
|
+
useLiveBroadcastPoll,
|
|
6
|
+
useBroadcastSessionApi,
|
|
7
|
+
useValidateBroadcastPass,
|
|
8
|
+
} from "../../../data/queries/useBroadcasts";
|
|
9
|
+
import {
|
|
10
|
+
broadcastSessionKey,
|
|
11
|
+
hasBroadcastEnded,
|
|
12
|
+
needsPassValidation,
|
|
13
|
+
selectBroadcastScreen,
|
|
14
|
+
type BroadcastScreen,
|
|
15
|
+
} from "./broadcastState";
|
|
4
16
|
|
|
5
17
|
const errMessage = (e: unknown) =>
|
|
6
18
|
(e as { response?: { data?: { message?: string } } })?.response?.data?.message || "Invalid pass.";
|
|
7
19
|
|
|
20
|
+
/** How often a watching viewer tells the server it is still there. */
|
|
21
|
+
const SESSION_PING_MS = 15000;
|
|
22
|
+
|
|
23
|
+
const readSessionId = (broadcastId?: string): string | null => {
|
|
24
|
+
if (!broadcastId || typeof localStorage === "undefined") return null;
|
|
25
|
+
return localStorage.getItem(broadcastSessionKey(broadcastId));
|
|
26
|
+
};
|
|
27
|
+
|
|
8
28
|
/**
|
|
9
|
-
* Headless broadcast watch:
|
|
10
|
-
*
|
|
11
|
-
*
|
|
29
|
+
* Headless broadcast watch: everything the watch page does apart from drawing it.
|
|
30
|
+
*
|
|
31
|
+
* Reads the broadcast by id, polls a second copy so the end of a stream reaches
|
|
32
|
+
* a viewer who is already watching, re-opens a session held from an earlier
|
|
33
|
+
* visit, gates the whole thing behind pass validation, and keeps the session
|
|
34
|
+
* heartbeat running while the broadcast is on.
|
|
35
|
+
*
|
|
36
|
+
* The render decision itself is `screen`, computed by the pure
|
|
37
|
+
* `selectBroadcastScreen`, so what is on the page is decided by rules that can
|
|
38
|
+
* be checked without a browser.
|
|
12
39
|
*/
|
|
13
40
|
export function useBroadcastWatch(broadcastId?: string) {
|
|
14
|
-
const
|
|
41
|
+
const broadcastQuery = useLiveBroadcast(broadcastId);
|
|
42
|
+
const pollQuery = useLiveBroadcastPoll(broadcastId);
|
|
15
43
|
const validatePass = useValidateBroadcastPass();
|
|
44
|
+
const sessionApi = useBroadcastSessionApi();
|
|
45
|
+
|
|
16
46
|
const [pass, setPass] = useState<IBroadcastPass | null>(null);
|
|
47
|
+
const [hasValidPass, setHasValidPass] = useState(false);
|
|
48
|
+
const [isSessionLoading, setIsSessionLoading] = useState(true);
|
|
17
49
|
const [error, setError] = useState<string | null>(null);
|
|
50
|
+
const pingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
18
51
|
|
|
19
|
-
const broadcast =
|
|
20
|
-
|
|
21
|
-
|
|
52
|
+
const broadcast = broadcastQuery.data;
|
|
53
|
+
const polled = pollQuery.data;
|
|
54
|
+
|
|
55
|
+
const startPing = useCallback(
|
|
56
|
+
(sessionId: string) => {
|
|
57
|
+
if (!broadcastId) return;
|
|
58
|
+
if (pingRef.current) clearInterval(pingRef.current);
|
|
59
|
+
pingRef.current = setInterval(() => {
|
|
60
|
+
sessionApi.sessionPing(broadcastId, sessionId).catch(() => {
|
|
61
|
+
// A dropped heartbeat is not worth interrupting the stream over. The
|
|
62
|
+
// server ages the session out and the next ping re-establishes it.
|
|
63
|
+
});
|
|
64
|
+
}, SESSION_PING_MS);
|
|
65
|
+
},
|
|
66
|
+
// `sessionApi` is rebuilt every render (it closes over the Axios client), so
|
|
67
|
+
// it is deliberately not a dependency: including it would restart the
|
|
68
|
+
// heartbeat on every render and reset the interval before it ever fired.
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
|
+
[broadcastId],
|
|
22
71
|
);
|
|
23
72
|
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
73
|
+
// Re-open a session parked in storage by an earlier visit. Until this settles
|
|
74
|
+
// the page shows nothing, so a returning viewer never sees the ticket box
|
|
75
|
+
// flash before their existing pass is recognised.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!broadcastId) return;
|
|
78
|
+
const sessionId = readSessionId(broadcastId);
|
|
79
|
+
if (!sessionId) {
|
|
80
|
+
setIsSessionLoading(false);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
let cancelled = false;
|
|
84
|
+
sessionApi
|
|
85
|
+
.validateSession(broadcastId, sessionId)
|
|
86
|
+
.then((restored) => {
|
|
87
|
+
if (cancelled) return;
|
|
88
|
+
setPass(restored);
|
|
89
|
+
setHasValidPass(true);
|
|
90
|
+
startPing(restored.sessionId);
|
|
91
|
+
})
|
|
92
|
+
.catch(() => {
|
|
93
|
+
// A session the server no longer honours simply means "ask for the
|
|
94
|
+
// ticket again". It is not an error the viewer needs to read.
|
|
95
|
+
})
|
|
96
|
+
.finally(() => {
|
|
97
|
+
if (!cancelled) setIsSessionLoading(false);
|
|
98
|
+
});
|
|
99
|
+
return () => {
|
|
100
|
+
cancelled = true;
|
|
101
|
+
};
|
|
102
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
103
|
+
}, [broadcastId, startPing]);
|
|
34
104
|
|
|
105
|
+
const isEnded = hasBroadcastEnded(broadcast, polled);
|
|
106
|
+
|
|
107
|
+
// Once it has ended there is nobody to be counted as watching.
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (isEnded && pingRef.current) {
|
|
110
|
+
clearInterval(pingRef.current);
|
|
111
|
+
pingRef.current = null;
|
|
112
|
+
}
|
|
113
|
+
}, [isEnded]);
|
|
114
|
+
|
|
115
|
+
useEffect(
|
|
116
|
+
() => () => {
|
|
117
|
+
if (pingRef.current) clearInterval(pingRef.current);
|
|
118
|
+
},
|
|
119
|
+
[],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Present a ticket code. On acceptance the session id is parked in storage so
|
|
124
|
+
* a refresh does not re-ask for it, and the heartbeat starts.
|
|
125
|
+
*/
|
|
35
126
|
const validate = async (eventPassId: string, sessionId?: string) => {
|
|
36
127
|
setError(null);
|
|
37
128
|
if (!broadcastId) return;
|
|
38
129
|
try {
|
|
39
|
-
const result = await validatePass.mutateAsync({
|
|
130
|
+
const result = await validatePass.mutateAsync({
|
|
131
|
+
broadcastId,
|
|
132
|
+
eventPassId,
|
|
133
|
+
sessionId: sessionId ?? readSessionId(broadcastId) ?? undefined,
|
|
134
|
+
});
|
|
135
|
+
setHasValidPass(true);
|
|
136
|
+
if (typeof localStorage !== "undefined") {
|
|
137
|
+
localStorage.setItem(broadcastSessionKey(broadcastId), result.sessionId);
|
|
138
|
+
}
|
|
40
139
|
setPass(result);
|
|
140
|
+
startPing(result.sessionId);
|
|
41
141
|
return result;
|
|
42
142
|
} catch (e) {
|
|
43
143
|
setError(errMessage(e));
|
|
@@ -45,15 +145,68 @@ export function useBroadcastWatch(broadcastId?: string) {
|
|
|
45
145
|
}
|
|
46
146
|
};
|
|
47
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Leave deliberately. The server is told first (so the audience count drops
|
|
150
|
+
* now rather than when the heartbeat ages out), then the stored session goes,
|
|
151
|
+
* so the next visit starts clean.
|
|
152
|
+
*/
|
|
153
|
+
const leave = useCallback(() => {
|
|
154
|
+
if (!broadcastId) return;
|
|
155
|
+
sessionApi.leave(broadcastId, readSessionId(broadcastId) ?? undefined).catch(() => {
|
|
156
|
+
// Best effort. The session ages out on its own.
|
|
157
|
+
});
|
|
158
|
+
if (typeof localStorage !== "undefined") {
|
|
159
|
+
localStorage.removeItem(broadcastSessionKey(broadcastId));
|
|
160
|
+
}
|
|
161
|
+
setHasValidPass(false);
|
|
162
|
+
if (pingRef.current) {
|
|
163
|
+
clearInterval(pingRef.current);
|
|
164
|
+
pingRef.current = null;
|
|
165
|
+
}
|
|
166
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
167
|
+
}, [broadcastId]);
|
|
168
|
+
|
|
169
|
+
const isLive = !!broadcast?.startedAt && !broadcast?.endedAt;
|
|
170
|
+
const status: "scheduled" | "live" | "ended" | undefined = !broadcast
|
|
171
|
+
? undefined
|
|
172
|
+
: isEnded
|
|
173
|
+
? "ended"
|
|
174
|
+
: isLive
|
|
175
|
+
? "live"
|
|
176
|
+
: "scheduled";
|
|
177
|
+
|
|
178
|
+
const screen: BroadcastScreen = selectBroadcastScreen({
|
|
179
|
+
broadcast,
|
|
180
|
+
polled,
|
|
181
|
+
isBroadcastLoading: broadcastQuery.isLoading,
|
|
182
|
+
isSessionLoading,
|
|
183
|
+
error: broadcastQuery.error,
|
|
184
|
+
hasValidPass,
|
|
185
|
+
pass,
|
|
186
|
+
});
|
|
187
|
+
|
|
48
188
|
return {
|
|
49
189
|
broadcast,
|
|
50
|
-
|
|
190
|
+
/** The polled copy. Only its `endedAt` is acted on. */
|
|
191
|
+
polled,
|
|
192
|
+
isLoading: broadcastQuery.isLoading,
|
|
193
|
+
/** True while a stored session is being re-checked. */
|
|
194
|
+
isSessionLoading,
|
|
195
|
+
/** The failure of the broadcast READ, as opposed to a rejected ticket. */
|
|
196
|
+
loadError: broadcastQuery.error,
|
|
51
197
|
status,
|
|
52
198
|
isLive,
|
|
53
199
|
isEnded,
|
|
200
|
+
/** Does this broadcast sit behind a ticket at all? */
|
|
201
|
+
isGated: needsPassValidation(broadcast),
|
|
54
202
|
pass,
|
|
203
|
+
hasValidPass,
|
|
55
204
|
validate,
|
|
205
|
+
leave,
|
|
56
206
|
isValidating: validatePass.isPending,
|
|
207
|
+
/** The rejected-ticket message, for the viewer to read. */
|
|
57
208
|
error,
|
|
209
|
+
/** What to draw. */
|
|
210
|
+
screen,
|
|
58
211
|
};
|
|
59
212
|
}
|