@tribe-nest/forge 3.29.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/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useMyBookings.ts +9 -1
- package/src/i18n/de.json +59 -0
- package/src/i18n/en.json +59 -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 +36 -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/styled/AccountDashboard.tsx +92 -3
- 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/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
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import type { ITicket } from "../../../types/models";
|
|
3
|
-
import {
|
|
4
|
-
clampChosenAmount,
|
|
5
|
-
isPayWhatYouWant,
|
|
6
|
-
pwywDefaultAmount,
|
|
7
|
-
pwywMaximum,
|
|
8
|
-
resolveUnitPrice,
|
|
9
|
-
ticketSubtotals,
|
|
10
|
-
} from "../pwyw";
|
|
3
|
+
import { checkoutAmounts, clampChosenAmount, isPayWhatYouWant, pwywDefaultAmount, pwywMaximum, resolveUnitPrice, ticketSubtotals } from "../pwyw";
|
|
11
4
|
|
|
12
5
|
/**
|
|
13
6
|
* The pay-what-you-want arithmetic shared by BOTH storefront stacks (Forge and
|
|
@@ -155,3 +148,67 @@ describe("pwyw helpers", () => {
|
|
|
155
148
|
});
|
|
156
149
|
});
|
|
157
150
|
});
|
|
151
|
+
|
|
152
|
+
describe("what actually gets sent as `amounts`", () => {
|
|
153
|
+
/**
|
|
154
|
+
* The server takes `amounts` as `z.number().positive()`. A zero answers
|
|
155
|
+
* `amounts.<id>: Amount must be a positive number` and the checkout fails
|
|
156
|
+
* outright, so a free pay-what-you-like ticket could not be bought at all.
|
|
157
|
+
* These assert the PAYLOAD rather than the displayed price, which is why the
|
|
158
|
+
* existing subtotal tests could not see it.
|
|
159
|
+
*/
|
|
160
|
+
const pwyw = (over: Partial<ITicket> = {}): ITicket =>
|
|
161
|
+
({ id: "t1", price: 0, payWhatYouWant: true, ...over }) as ITicket;
|
|
162
|
+
|
|
163
|
+
it("sends NOTHING for a free pay-what-you-like tier the buyer left alone", () => {
|
|
164
|
+
// The reported failure. Floor 0, no suggestion, so both the default and the
|
|
165
|
+
// clamp hand back 0.
|
|
166
|
+
const sent = checkoutAmounts({ tickets: [pwyw()], quantities: { t1: 1 }, chosen: {} });
|
|
167
|
+
|
|
168
|
+
expect(sent).toEqual({});
|
|
169
|
+
expect(Object.values(sent).every((v) => v > 0)).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("sends nothing when the buyer TYPES a zero on a free tier", () => {
|
|
173
|
+
const sent = checkoutAmounts({ tickets: [pwyw()], quantities: { t1: 1 }, chosen: { t1: 0 } });
|
|
174
|
+
expect(sent).toEqual({});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("still sends a real amount on a free tier when the buyer chooses to pay", () => {
|
|
178
|
+
// Omitting must not mean "never send"; a free tier is exactly where a
|
|
179
|
+
// voluntary payment matters most.
|
|
180
|
+
const sent = checkoutAmounts({ tickets: [pwyw()], quantities: { t1: 1 }, chosen: { t1: 15 } });
|
|
181
|
+
expect(sent).toEqual({ t1: 15 });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("sends the floor on a PAID tier, which is positive and therefore legal", () => {
|
|
185
|
+
const sent = checkoutAmounts({
|
|
186
|
+
tickets: [pwyw({ price: 10 })],
|
|
187
|
+
quantities: { t1: 1 },
|
|
188
|
+
chosen: { t1: 4 },
|
|
189
|
+
});
|
|
190
|
+
// Clamped up to the floor rather than dropped: 10 is a positive number and
|
|
191
|
+
// says something true.
|
|
192
|
+
expect(sent).toEqual({ t1: 10 });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("ignores a tier with no quantity, and a tier that is not PWYW", () => {
|
|
196
|
+
const sent = checkoutAmounts({
|
|
197
|
+
tickets: [pwyw({ id: "none" }), { id: "fixed", price: 20, payWhatYouWant: false } as ITicket],
|
|
198
|
+
quantities: { none: 0, fixed: 2 },
|
|
199
|
+
chosen: { none: 50, fixed: 99 },
|
|
200
|
+
});
|
|
201
|
+
expect(sent).toEqual({});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("never emits a non-positive value for any mix of tiers", () => {
|
|
205
|
+
const sent = checkoutAmounts({
|
|
206
|
+
tickets: [pwyw({ id: "free" }), pwyw({ id: "paid", price: 12 })],
|
|
207
|
+
quantities: { free: 1, paid: 1 },
|
|
208
|
+
chosen: {},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(sent).toEqual({ paid: 12 });
|
|
212
|
+
expect(Object.values(sent).some((v) => v <= 0)).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { MembershipTier } from "../../types/models";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Membership pricing arithmetic: which billing cycles a tier actually offers,
|
|
5
|
+
* what a pay-what-you-want cycle's floor is, and the ONE number that may be
|
|
6
|
+
* sent to `POST /public/payments/subscriptions`.
|
|
7
|
+
*
|
|
8
|
+
* ## Why this is a module and not four lines inside the hook
|
|
9
|
+
*
|
|
10
|
+
* Every rule here has a wrong-charge or a dead-end on the other side of it, and
|
|
11
|
+
* none of them is reachable from a React test without a provider tree:
|
|
12
|
+
*
|
|
13
|
+
* - **A cycle a tier does not sell must not be selectable.** The default cycle
|
|
14
|
+
* used to be the literal `"month"`, so a tier priced YEARLY only was read as
|
|
15
|
+
* "no monthly price, therefore free" and activated as a free membership. The
|
|
16
|
+
* fan got the tier and the artist got nothing.
|
|
17
|
+
* - **The floor belongs to the CYCLE, not the tier.** `payWhatYouWantMinimum`
|
|
18
|
+
* is the monthly floor and `payWhatYouWantYearlyMinimum` the yearly one.
|
|
19
|
+
* Reading the monthly one on a yearly subscription understates the minimum
|
|
20
|
+
* by a factor of twelve.
|
|
21
|
+
* - **A zero must never be sent.** The server takes the amount as
|
|
22
|
+
* `z.number()` and then refuses anything `<= 0` with
|
|
23
|
+
* `amount_must_be_positive`, so a pay-what-you-want box left at its initial
|
|
24
|
+
* 0 is not "pay nothing", it is a subscribe button that always fails.
|
|
25
|
+
*
|
|
26
|
+
* ## Units
|
|
27
|
+
*
|
|
28
|
+
* Every number in and out of here is MAJOR units (19.99, not 1999) in the
|
|
29
|
+
* TENANT'S SETTLEMENT currency (`useSiteConfig().currency`), because that is
|
|
30
|
+
* what the tier rows hold and what the server converts with `toMinorUnits`.
|
|
31
|
+
* A visitor's chosen display currency converts on the way to the SCREEN and
|
|
32
|
+
* never on the way to the API: convert what is sent and the fan is charged a
|
|
33
|
+
* number nobody agreed to.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export type BillingCycle = "month" | "year";
|
|
37
|
+
|
|
38
|
+
/** A tier's own numbers, narrowed to what pricing needs. */
|
|
39
|
+
export type PricedTier = Pick<
|
|
40
|
+
MembershipTier,
|
|
41
|
+
| "payWhatYouWant"
|
|
42
|
+
| "payWhatYouWantMinimum"
|
|
43
|
+
| "payWhatYouWantYearlyMinimum"
|
|
44
|
+
| "payWhatYouWantMaximum"
|
|
45
|
+
| "payWhatYouWantYearlyMaximum"
|
|
46
|
+
| "priceMonthly"
|
|
47
|
+
| "priceYearly"
|
|
48
|
+
>;
|
|
49
|
+
|
|
50
|
+
const positive = (value: number | null | undefined): number => {
|
|
51
|
+
const parsed = typeof value === "number" ? value : Number(value ?? 0);
|
|
52
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The lowest amount this cycle may be bought at.
|
|
57
|
+
*
|
|
58
|
+
* Pay-what-you-want: the cycle's own minimum. Fixed price: the cycle's price,
|
|
59
|
+
* which is also the only amount the server accepts (`amount !== cyclePrice`
|
|
60
|
+
* is rejected outright). 0 means "this cycle is free or not offered", which
|
|
61
|
+
* `cycleIsOffered` separates.
|
|
62
|
+
*/
|
|
63
|
+
export const cycleFloor = (tier: PricedTier, cycle: BillingCycle): number => {
|
|
64
|
+
if (tier.payWhatYouWant) {
|
|
65
|
+
return positive(cycle === "month" ? tier.payWhatYouWantMinimum : tier.payWhatYouWantYearlyMinimum);
|
|
66
|
+
}
|
|
67
|
+
return positive(cycle === "month" ? tier.priceMonthly : tier.priceYearly);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The operator's ceiling for a pay-what-you-want cycle, or `null` for none.
|
|
72
|
+
*
|
|
73
|
+
* Exposed for display only. It is deliberately NOT applied to the amount that
|
|
74
|
+
* is sent: neither the client app nor the server enforces a membership PWYW
|
|
75
|
+
* maximum today, and clamping here would charge less than the total the fan
|
|
76
|
+
* just read on screen.
|
|
77
|
+
*/
|
|
78
|
+
export const cycleCeiling = (tier: PricedTier, cycle: BillingCycle): number | null => {
|
|
79
|
+
if (!tier.payWhatYouWant) return null;
|
|
80
|
+
const raw = cycle === "month" ? tier.payWhatYouWantMaximum : tier.payWhatYouWantYearlyMaximum;
|
|
81
|
+
const value = positive(raw);
|
|
82
|
+
return value > 0 ? value : null;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Does the tier sell this cycle at all? A cycle with no price is not on offer. */
|
|
86
|
+
export const cycleIsOffered = (tier: PricedTier, cycle: BillingCycle): boolean => cycleFloor(tier, cycle) > 0;
|
|
87
|
+
|
|
88
|
+
/** Both answers at once, for rendering the billing-cycle radios. */
|
|
89
|
+
export const offeredCycles = (tier: PricedTier): { month: boolean; year: boolean } => ({
|
|
90
|
+
month: cycleIsOffered(tier, "month"),
|
|
91
|
+
year: cycleIsOffered(tier, "year"),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The cycle a tier opens on: monthly when it is sold, otherwise yearly.
|
|
96
|
+
*
|
|
97
|
+
* A tier that sells NEITHER is a free tier, where the cycle is never sent and
|
|
98
|
+
* only decides whether the summary reads "per month" or "per year".
|
|
99
|
+
*/
|
|
100
|
+
export const defaultCycle = (tier: PricedTier): BillingCycle =>
|
|
101
|
+
cycleIsOffered(tier, "month") ? "month" : cycleIsOffered(tier, "year") ? "year" : "month";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Free means free on THIS cycle: no pay-what-you-want floor and no price.
|
|
105
|
+
* A free tier is activated through `/subscriptions/free`, which takes no
|
|
106
|
+
* amount, so this is the branch that decides which endpoint is called.
|
|
107
|
+
*/
|
|
108
|
+
export const cycleIsFree = (tier: PricedTier, cycle: BillingCycle): boolean =>
|
|
109
|
+
!tier.payWhatYouWant && cycleFloor(tier, cycle) === 0;
|
|
110
|
+
|
|
111
|
+
/** What the amount box starts at: the floor for the cycle. */
|
|
112
|
+
export const defaultChosenAmount = (tier: PricedTier, cycle: BillingCycle): number =>
|
|
113
|
+
tier.payWhatYouWant ? cycleFloor(tier, cycle) : 0;
|
|
114
|
+
|
|
115
|
+
/** Lift a fan-entered figure up to the floor. Never lowers it to a ceiling. */
|
|
116
|
+
export const clampMembershipAmount = (tier: PricedTier, cycle: BillingCycle, chosen: number | null | undefined): number => {
|
|
117
|
+
const floor = cycleFloor(tier, cycle);
|
|
118
|
+
const parsed = typeof chosen === "number" ? chosen : Number(chosen ?? 0);
|
|
119
|
+
if (!Number.isFinite(parsed) || parsed < floor) return floor;
|
|
120
|
+
return parsed;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The number to SEND for a paid subscription, in settlement-currency major
|
|
125
|
+
* units.
|
|
126
|
+
*
|
|
127
|
+
* A fixed tier ignores `chosen` exactly as the server does, so a stale amount
|
|
128
|
+
* left in state after switching from a pay-what-you-want tier cannot change
|
|
129
|
+
* what is charged.
|
|
130
|
+
*/
|
|
131
|
+
export const resolveSubscriptionAmount = (
|
|
132
|
+
tier: PricedTier,
|
|
133
|
+
cycle: BillingCycle,
|
|
134
|
+
chosen: number | null | undefined,
|
|
135
|
+
): number => (tier.payWhatYouWant ? clampMembershipAmount(tier, cycle, chosen) : cycleFloor(tier, cycle));
|
|
136
|
+
|
|
137
|
+
/** Why an amount cannot be sent. `null` when it can. */
|
|
138
|
+
export type MembershipAmountRefusal =
|
|
139
|
+
| { reason: "below_minimum"; minimum: number }
|
|
140
|
+
| { reason: "not_positive" };
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The check that runs before the request, not after the 400.
|
|
144
|
+
*
|
|
145
|
+
* Two refusals, and the second is the one that bites hardest: a
|
|
146
|
+
* pay-what-you-want box that is still on its initial 0, or one the fan cleared,
|
|
147
|
+
* resolves to 0 on a tier whose floor is 0, and the server answers
|
|
148
|
+
* `amount_must_be_positive`. Saying so here turns an unexplained failure into a
|
|
149
|
+
* field error next to the field.
|
|
150
|
+
*/
|
|
151
|
+
export const refuseMembershipAmount = (
|
|
152
|
+
tier: PricedTier,
|
|
153
|
+
cycle: BillingCycle,
|
|
154
|
+
chosen: number | null | undefined,
|
|
155
|
+
): MembershipAmountRefusal | null => {
|
|
156
|
+
if (cycleIsFree(tier, cycle)) return null;
|
|
157
|
+
const floor = cycleFloor(tier, cycle);
|
|
158
|
+
if (tier.payWhatYouWant) {
|
|
159
|
+
const parsed = typeof chosen === "number" ? chosen : Number(chosen ?? 0);
|
|
160
|
+
if (!Number.isFinite(parsed) || parsed < floor) return { reason: "below_minimum", minimum: floor };
|
|
161
|
+
}
|
|
162
|
+
if (resolveSubscriptionAmount(tier, cycle, chosen) <= 0) return { reason: "not_positive" };
|
|
163
|
+
return null;
|
|
164
|
+
};
|
package/src/ui/format/pwyw.ts
CHANGED
|
@@ -93,3 +93,40 @@ export function ticketSubtotals(input: {
|
|
|
93
93
|
}
|
|
94
94
|
return { paid, floor };
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The `amounts` map to SEND for a selection, keyed by ticket id.
|
|
99
|
+
*
|
|
100
|
+
* Only PWYW tiers with a quantity appear, and only with a POSITIVE amount. That
|
|
101
|
+
* last rule is not a tidy-up: the server takes `amounts` as
|
|
102
|
+
* `z.number().positive()`, so a zero answers
|
|
103
|
+
*
|
|
104
|
+
* amounts.<id>: Amount must be a positive number
|
|
105
|
+
*
|
|
106
|
+
* and the checkout fails outright. It is reachable on any PWYW tier whose floor
|
|
107
|
+
* is zero, a free "pay what you like" ticket: with no suggestion
|
|
108
|
+
* `pwywDefaultAmount` returns the floor, and `clampChosenAmount` returns the
|
|
109
|
+
* floor for anything below it, so both paths hand back 0 and the buyer cannot
|
|
110
|
+
* check out at all.
|
|
111
|
+
*
|
|
112
|
+
* Omitting the key says what the 0 was trying to say. The server reads a missing
|
|
113
|
+
* entry as `NaN`, fails `Number.isFinite`, and falls through to the tier's list
|
|
114
|
+
* price, which for such a tier IS zero.
|
|
115
|
+
*/
|
|
116
|
+
export function checkoutAmounts(input: {
|
|
117
|
+
tickets: ITicket[];
|
|
118
|
+
quantities: Record<string, number>;
|
|
119
|
+
/** What the buyer typed, where they typed anything. */
|
|
120
|
+
chosen: Record<string, number | null | undefined>;
|
|
121
|
+
}): Record<string, number> {
|
|
122
|
+
const out: Record<string, number> = {};
|
|
123
|
+
for (const ticket of input.tickets) {
|
|
124
|
+
if (!isPayWhatYouWant(ticket) || (input.quantities[ticket.id] ?? 0) <= 0) continue;
|
|
125
|
+
const amount =
|
|
126
|
+
input.chosen[ticket.id] != null
|
|
127
|
+
? resolveUnitPrice(ticket, input.chosen[ticket.id])
|
|
128
|
+
: pwywDefaultAmount(ticket);
|
|
129
|
+
if (amount > 0) out[ticket.id] = amount;
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
@@ -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
|
+
}
|