@tribe-nest/forge 3.26.0 → 3.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/package.json +6 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  6. package/src/client/activeLocale.ts +34 -0
  7. package/src/client/createForgeClient.ts +13 -0
  8. package/src/contexts/CartContext.tsx +76 -0
  9. package/src/contexts/PublicAuthContext.tsx +34 -5
  10. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  11. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  12. package/src/data/queries/useBroadcasts.ts +151 -0
  13. package/src/data/queries/useCheckouts.ts +83 -0
  14. package/src/data/queries/useMyBookings.ts +9 -1
  15. package/src/data/queries/useWebsite.ts +7 -0
  16. package/src/i18n/CONVENTION.md +184 -0
  17. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  18. package/src/i18n/de.json +1019 -0
  19. package/src/i18n/en.json +1019 -0
  20. package/src/i18n/index.ts +125 -0
  21. package/src/index.ts +21 -0
  22. package/src/provider/ForgeAppProvider.tsx +2 -1
  23. package/src/provider/SiteConfigProvider.tsx +11 -0
  24. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  25. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  26. package/src/ui/format/membershipPwyw.ts +164 -0
  27. package/src/ui/format/pwyw.ts +37 -0
  28. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  29. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  30. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  31. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  32. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  33. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  34. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  35. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  36. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  37. package/src/ui/headless/index.ts +32 -0
  38. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  39. package/src/ui/index.ts +38 -0
  40. package/src/ui/media/CallHelpHint.tsx +87 -0
  41. package/src/ui/media/CallStage.tsx +542 -0
  42. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  43. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  44. package/src/ui/media/_tests/callState.spec.ts +452 -0
  45. package/src/ui/media/_tests/fakeNode.ts +178 -0
  46. package/src/ui/media/bookingSession.tsx +194 -0
  47. package/src/ui/media/callState.ts +341 -0
  48. package/src/ui/media/index.ts +135 -0
  49. package/src/ui/shell/TribeNestApp.tsx +36 -27
  50. package/src/ui/styled/AccountDashboard.tsx +289 -96
  51. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  52. package/src/ui/styled/AudioPlayer.tsx +14 -6
  53. package/src/ui/styled/BlogCategory.tsx +4 -2
  54. package/src/ui/styled/BlogComments.tsx +27 -20
  55. package/src/ui/styled/BlogList.tsx +5 -3
  56. package/src/ui/styled/BlogPost.tsx +14 -11
  57. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  58. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  59. package/src/ui/styled/CancellationTerms.tsx +3 -1
  60. package/src/ui/styled/Cart.tsx +32 -14
  61. package/src/ui/styled/ChatRoom.tsx +65 -20
  62. package/src/ui/styled/Checkout.tsx +151 -89
  63. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  64. package/src/ui/styled/CoachingBooking.tsx +71 -44
  65. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  66. package/src/ui/styled/CoachingDetail.tsx +15 -11
  67. package/src/ui/styled/CohortPage.tsx +10 -8
  68. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  69. package/src/ui/styled/ContactForm.tsx +31 -12
  70. package/src/ui/styled/CookieConsent.tsx +19 -15
  71. package/src/ui/styled/CourseAccess.tsx +54 -47
  72. package/src/ui/styled/CourseCheckout.tsx +56 -38
  73. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  74. package/src/ui/styled/CourseDetail.tsx +21 -15
  75. package/src/ui/styled/CoursesGrid.tsx +4 -2
  76. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  77. package/src/ui/styled/DiscountCode.tsx +22 -18
  78. package/src/ui/styled/DonationButton.tsx +52 -17
  79. package/src/ui/styled/DonationPage.tsx +4 -4
  80. package/src/ui/styled/EmailListForm.tsx +22 -9
  81. package/src/ui/styled/EventConfirmation.tsx +37 -14
  82. package/src/ui/styled/EventCountdown.tsx +10 -6
  83. package/src/ui/styled/EventDetail.tsx +8 -6
  84. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  85. package/src/ui/styled/EventTickets.tsx +232 -108
  86. package/src/ui/styled/EventWaitlist.tsx +42 -33
  87. package/src/ui/styled/EventsList.tsx +15 -13
  88. package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
  89. package/src/ui/styled/FormRenderer.tsx +12 -7
  90. package/src/ui/styled/InstallBanner.tsx +21 -18
  91. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  92. package/src/ui/styled/InvoicePayment.tsx +37 -16
  93. package/src/ui/styled/LeadMagnet.tsx +9 -7
  94. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  95. package/src/ui/styled/Loading.tsx +7 -1
  96. package/src/ui/styled/LoginForm.tsx +32 -14
  97. package/src/ui/styled/MembershipCheckout.tsx +341 -49
  98. package/src/ui/styled/MembershipTiers.tsx +34 -18
  99. package/src/ui/styled/OfferButton.tsx +33 -13
  100. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  101. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  102. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  103. package/src/ui/styled/PodcastList.tsx +9 -3
  104. package/src/ui/styled/PodcastShow.tsx +29 -7
  105. package/src/ui/styled/PostsFeed.tsx +10 -8
  106. package/src/ui/styled/PresaleCode.tsx +8 -11
  107. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  108. package/src/ui/styled/ProductDetail.tsx +58 -28
  109. package/src/ui/styled/ReactionBar.tsx +4 -2
  110. package/src/ui/styled/ReplayList.tsx +24 -13
  111. package/src/ui/styled/ResetPasswordForm.tsx +32 -15
  112. package/src/ui/styled/ResumeCart.tsx +289 -0
  113. package/src/ui/styled/ReviewForm.tsx +52 -58
  114. package/src/ui/styled/SignupForm.tsx +34 -16
  115. package/src/ui/styled/TicketTransfer.tsx +55 -54
  116. package/src/ui/styled/UserMenu.tsx +16 -14
  117. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  118. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  119. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  120. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
  121. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  122. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  123. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  124. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  125. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  126. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  127. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  128. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  129. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
@@ -0,0 +1,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 { useMemo, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import type { IBroadcastPass } from "../../../types/models";
3
- import { useLiveBroadcasts, useValidateBroadcastPass } from "../../../data/queries/useBroadcasts";
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: resolves a broadcast from the live list and gates it
10
- * behind pass validation. Composes `useLiveBroadcasts` + `useValidateBroadcastPass`.
11
- * Once `pass` is set the caller can mount its player with the returned playback info.
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 broadcasts = useLiveBroadcasts();
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 = useMemo(
20
- () => broadcasts.data?.find((b) => b.id === broadcastId),
21
- [broadcasts.data, broadcastId],
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
- // Derive lifecycle from the broadcast timestamps (no explicit status field).
25
- const isEnded = !!broadcast?.endedAt;
26
- const isLive = !!broadcast?.startedAt && !broadcast?.endedAt;
27
- const status: "scheduled" | "live" | "ended" | undefined = !broadcast
28
- ? undefined
29
- : isEnded
30
- ? "ended"
31
- : isLive
32
- ? "live"
33
- : "scheduled";
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({ broadcastId, eventPassId, sessionId });
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
- isLoading: broadcasts.isLoading,
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
  }