@tribe-nest/forge 3.29.0 → 3.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -3
- package/src/_tests/publishedResolvability.spec.ts +184 -0
- package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
- package/src/_tests/workspaceAliases.ts +40 -0
- package/src/contexts/CartContext.tsx +17 -1
- package/src/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/CartContext.spec.tsx +36 -0
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useMyBookings.ts +18 -1
- package/src/i18n/_tests/translationKeys.spec.ts +15 -0
- package/src/i18n/de.json +97 -0
- package/src/i18n/en.json +97 -0
- package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
- package/src/ui/format/_tests/pwyw.spec.ts +65 -8
- package/src/ui/format/membershipPwyw.ts +164 -0
- package/src/ui/format/pwyw.ts +37 -0
- package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
- package/src/ui/headless/broadcast/broadcastState.ts +158 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
- package/src/ui/headless/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +14 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +61 -0
- package/src/ui/media/BookingCallScreen.tsx +33 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +633 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +931 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +227 -0
- package/src/ui/media/_tests/callState.spec.ts +499 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +182 -0
- package/src/ui/media/bookingWindow.ts +81 -0
- package/src/ui/media/callState.ts +360 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/styled/AccountDashboard.tsx +193 -4
- package/src/ui/styled/BroadcastWatch.tsx +107 -0
- package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
- package/src/ui/styled/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/LoginForm.tsx +10 -0
- package/src/ui/styled/MembershipCheckout.tsx +318 -45
- package/src/ui/styled/MembershipTiers.tsx +10 -3
- package/src/ui/styled/ResetPasswordForm.tsx +5 -0
- package/src/ui/styled/SignupForm.tsx +5 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +166 -0
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
- package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
- package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
- package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
- package/src/ui/styled/community/CommunityComposer.tsx +182 -3
- package/src/ui/styled/community/CommunityFeed.tsx +36 -51
- package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
- package/src/ui/styled/forge-utilities.css +832 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { ForgeClientProvider } from "../../../provider/ForgeProvider";
|
|
5
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
6
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
7
|
+
import type { MyBooking } from "../../../data/queries/useMyBookings";
|
|
8
|
+
import { AccountDashboard } from "../AccountDashboard";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The account page's way INTO a coaching video call.
|
|
12
|
+
*
|
|
13
|
+
* The room, the hooks, the credential endpoint and `BookingCallProvider` all
|
|
14
|
+
* shipped, and on a code website none of it was reachable: the row for a
|
|
15
|
+
* video session printed "Video call on this site" and offered nothing to
|
|
16
|
+
* press, while admin's help text told coaches the client joins "from their
|
|
17
|
+
* account on your site". This pins the control the row now draws.
|
|
18
|
+
*
|
|
19
|
+
* Rendered through `react-dom/server`: no DOM, no effects, so the bookings
|
|
20
|
+
* query is SEEDED into the react-query cache. That keeps the real
|
|
21
|
+
* `useMyBookings` in the path, and a rename of its query key fails here. The
|
|
22
|
+
* call itself is behind a click and a `React.lazy`, so nothing here ever
|
|
23
|
+
* touches the media SDK: the spec is about the CONTROL, and it proves the SDK
|
|
24
|
+
* is not needed to decide whether to draw it.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const PROFILE_ID = "profile-1";
|
|
28
|
+
const ACCOUNT_ID = "account-1";
|
|
29
|
+
const MINUTE = 60_000;
|
|
30
|
+
|
|
31
|
+
const member = {
|
|
32
|
+
id: ACCOUNT_ID,
|
|
33
|
+
email: "client@example.com",
|
|
34
|
+
firstName: "Client",
|
|
35
|
+
lastName: "Person",
|
|
36
|
+
kind: "fan",
|
|
37
|
+
status: "active",
|
|
38
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
39
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
40
|
+
membership: null,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const booking = (overrides: Partial<MyBooking> = {}): MyBooking => {
|
|
44
|
+
const start = new Date(Date.now() + 5 * MINUTE);
|
|
45
|
+
const end = new Date(start.getTime() + 60 * MINUTE);
|
|
46
|
+
return {
|
|
47
|
+
id: "booking-1",
|
|
48
|
+
status: "confirmed",
|
|
49
|
+
email: "client@example.com",
|
|
50
|
+
firstName: "Client",
|
|
51
|
+
lastName: "Person",
|
|
52
|
+
totalAmount: 40,
|
|
53
|
+
totalAmountInChargedCurrency: null,
|
|
54
|
+
currency: "USD",
|
|
55
|
+
chargedCurrency: null,
|
|
56
|
+
refundState: null,
|
|
57
|
+
refundedAmountCents: null,
|
|
58
|
+
selfCancelledAt: null,
|
|
59
|
+
cancellationOutcome: null,
|
|
60
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
61
|
+
coachingBookingSlotId: "slot-1",
|
|
62
|
+
sessionStartTime: start.toISOString(),
|
|
63
|
+
sessionEndTime: end.toISOString(),
|
|
64
|
+
coachingProductId: "product-1",
|
|
65
|
+
coachingProductSlug: "video-session",
|
|
66
|
+
coachingProductTitle: "Video session",
|
|
67
|
+
coachingProductTimezone: null,
|
|
68
|
+
coachingProductDurationMinutes: 60,
|
|
69
|
+
cancellation: {
|
|
70
|
+
policy: null,
|
|
71
|
+
cutoffHours: null,
|
|
72
|
+
terms: null,
|
|
73
|
+
description: null,
|
|
74
|
+
canCancel: false,
|
|
75
|
+
reason: null,
|
|
76
|
+
deadline: null,
|
|
77
|
+
},
|
|
78
|
+
location: { type: "video" } as MyBooking["location"],
|
|
79
|
+
canReschedule: false,
|
|
80
|
+
rescheduleReason: null,
|
|
81
|
+
calendarUrl: null,
|
|
82
|
+
calendarWebcalUrl: null,
|
|
83
|
+
...overrides,
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function render(rows: MyBooking[]): string {
|
|
88
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
89
|
+
queryClient.setQueryData(["my-bookings", ACCOUNT_ID, PROFILE_ID, 1, 20], {
|
|
90
|
+
data: rows,
|
|
91
|
+
total: rows.length,
|
|
92
|
+
page: 1,
|
|
93
|
+
limit: 20,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return renderToStaticMarkup(
|
|
97
|
+
<QueryClientProvider client={queryClient}>
|
|
98
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
|
|
99
|
+
<PublicAuthContext.Provider
|
|
100
|
+
value={{ user: member, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}
|
|
101
|
+
>
|
|
102
|
+
<ForgeThemeProvider
|
|
103
|
+
theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
|
|
104
|
+
>
|
|
105
|
+
<AccountDashboard tab="bookings" onNavigateMembership={() => {}} />
|
|
106
|
+
</ForgeThemeProvider>
|
|
107
|
+
</PublicAuthContext.Provider>
|
|
108
|
+
</ForgeClientProvider>
|
|
109
|
+
</QueryClientProvider>,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe("AccountDashboard: the way into a coaching video call", () => {
|
|
114
|
+
it("REGRESSION: offers Join for a video session whose room is open", () => {
|
|
115
|
+
// Starts in 5 minutes: inside the 15-minutes-early window.
|
|
116
|
+
const html = render([booking()]);
|
|
117
|
+
|
|
118
|
+
expect(html).toContain("Video session");
|
|
119
|
+
expect(html).toContain("Join video call");
|
|
120
|
+
// The line still says where the session is; the control is in addition.
|
|
121
|
+
expect(html).toContain("Video call on this site");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("says when the room opens, rather than offering a Join that would 409", () => {
|
|
125
|
+
const start = new Date(Date.now() + 3 * 60 * MINUTE);
|
|
126
|
+
const html = render([
|
|
127
|
+
booking({
|
|
128
|
+
sessionStartTime: start.toISOString(),
|
|
129
|
+
sessionEndTime: new Date(start.getTime() + 60 * MINUTE).toISOString(),
|
|
130
|
+
}),
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
expect(html).not.toContain("Join video call");
|
|
134
|
+
expect(html).toContain("the room opens");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("says the room has closed once the window is over", () => {
|
|
138
|
+
const start = new Date(Date.now() - 5 * 60 * MINUTE);
|
|
139
|
+
const html = render([
|
|
140
|
+
booking({
|
|
141
|
+
sessionStartTime: start.toISOString(),
|
|
142
|
+
sessionEndTime: new Date(start.getTime() + 60 * MINUTE).toISOString(),
|
|
143
|
+
}),
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
expect(html).not.toContain("Join video call");
|
|
147
|
+
expect(html).toContain("room has closed");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("draws nothing for a session that is not a video call", () => {
|
|
151
|
+
const html = render([
|
|
152
|
+
booking({ location: { type: "online", url: "https://meet.example.com/x" } as MyBooking["location"] }),
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
expect(html).not.toContain("Join video call");
|
|
156
|
+
expect(html).not.toContain("the room opens");
|
|
157
|
+
expect(html).toContain("https://meet.example.com/x");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("draws nothing for a cancelled session, whose room the server withholds", () => {
|
|
161
|
+
const html = render([booking({ status: "canceled", selfCancelledAt: "2026-01-02T00:00:00.000Z", location: null })]);
|
|
162
|
+
|
|
163
|
+
expect(html).not.toContain("Join video call");
|
|
164
|
+
expect(html).not.toContain("the room opens");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { ForgeClientProvider } from "../../../provider/ForgeProvider";
|
|
5
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
6
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
7
|
+
import { communityKeys, type CommunitySpace } from "../../../data/queries/useCommunity";
|
|
8
|
+
import { AccountDashboard } from "../AccountDashboard";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The account page's way into the members corner.
|
|
12
|
+
*
|
|
13
|
+
* Before this, a signed-in member landed on the membership card and the only
|
|
14
|
+
* doors on it led back to billing: change the plan, cancel it, fix the card.
|
|
15
|
+
* The community they were paying for was reachable only by typing the URL.
|
|
16
|
+
*
|
|
17
|
+
* The gate is the server's own answer. `/public/community/spaces` returns the
|
|
18
|
+
* open spaces PLUS the ones gated to a tier the caller holds, so the list being
|
|
19
|
+
* empty is the server saying "there is nothing here you can open". Drawing the
|
|
20
|
+
* link anyway would walk a member into a bounce, which is why the empty case is
|
|
21
|
+
* asserted as hard as the happy one.
|
|
22
|
+
*
|
|
23
|
+
* Rendered through `react-dom/server`: no DOM, no effects, so the spaces query
|
|
24
|
+
* is SEEDED into the react-query cache rather than mocked. That keeps the real
|
|
25
|
+
* `useCommunitySpaces` in the path, and a rename of its query key fails here.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const PROFILE_ID = "profile-1";
|
|
29
|
+
|
|
30
|
+
const space = (overrides: Partial<CommunitySpace> = {}): CommunitySpace => ({
|
|
31
|
+
id: "space-1",
|
|
32
|
+
profileId: PROFILE_ID,
|
|
33
|
+
name: "Backstage",
|
|
34
|
+
slug: "backstage",
|
|
35
|
+
description: null,
|
|
36
|
+
type: "feed",
|
|
37
|
+
courseId: null,
|
|
38
|
+
order: 0,
|
|
39
|
+
archivedAt: null,
|
|
40
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
41
|
+
tiers: [],
|
|
42
|
+
...overrides,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** A signed-in member holding a paid tier that renews. */
|
|
46
|
+
const member = {
|
|
47
|
+
id: "account-1",
|
|
48
|
+
email: "fan@example.com",
|
|
49
|
+
firstName: "Fan",
|
|
50
|
+
lastName: "Person",
|
|
51
|
+
kind: "fan",
|
|
52
|
+
status: "active",
|
|
53
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
54
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
55
|
+
membership: {
|
|
56
|
+
id: "membership-1",
|
|
57
|
+
membershipTierId: "tier-1",
|
|
58
|
+
endDate: "2026-12-01T00:00:00.000Z",
|
|
59
|
+
status: "active",
|
|
60
|
+
startDate: "2026-01-01T00:00:00.000Z",
|
|
61
|
+
subscriptionAmount: 10,
|
|
62
|
+
subscriptionCurrency: "USD",
|
|
63
|
+
billingCycle: "month",
|
|
64
|
+
membershipTier: { id: "tier-1", name: "Inner Circle", benefits: [] },
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The membership tab as a Forge code website draws it.
|
|
70
|
+
*
|
|
71
|
+
* `spaces === null` stands for the query never having answered (the member has
|
|
72
|
+
* no accessible spaces cached), which is the state a non-member sits in.
|
|
73
|
+
*/
|
|
74
|
+
function render(opts: { spaces: CommunitySpace[] | null; onNavigateCommunity?: () => void }): string {
|
|
75
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
76
|
+
if (opts.spaces) queryClient.setQueryData(communityKeys.spaces(PROFILE_ID), opts.spaces);
|
|
77
|
+
|
|
78
|
+
return renderToStaticMarkup(
|
|
79
|
+
<QueryClientProvider client={queryClient}>
|
|
80
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
|
|
81
|
+
<PublicAuthContext.Provider
|
|
82
|
+
value={{ user: member, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}
|
|
83
|
+
>
|
|
84
|
+
<ForgeThemeProvider
|
|
85
|
+
theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
|
|
86
|
+
>
|
|
87
|
+
<AccountDashboard
|
|
88
|
+
tab="membership"
|
|
89
|
+
onNavigateMembership={() => {}}
|
|
90
|
+
onNavigateCommunity={opts.onNavigateCommunity}
|
|
91
|
+
/>
|
|
92
|
+
</ForgeThemeProvider>
|
|
93
|
+
</PublicAuthContext.Provider>
|
|
94
|
+
</ForgeClientProvider>
|
|
95
|
+
</QueryClientProvider>,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe("AccountDashboard: the way into the members corner", () => {
|
|
100
|
+
it("REGRESSION: offers the community to a member who has a space to open", () => {
|
|
101
|
+
// The gap this closes. Without it the membership card's only exits are
|
|
102
|
+
// Change and Cancel, and the community is unreachable from the account page.
|
|
103
|
+
const html = render({ spaces: [space()], onNavigateCommunity: () => {} });
|
|
104
|
+
|
|
105
|
+
expect(html).toContain("Community spaces");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("REGRESSION: says nothing when the member can open no space", () => {
|
|
109
|
+
// A link here would promise a door that is locked: the server returned no
|
|
110
|
+
// space this account may enter, so the honest account page is silent.
|
|
111
|
+
const html = render({ spaces: [], onNavigateCommunity: () => {} });
|
|
112
|
+
|
|
113
|
+
expect(html).not.toContain("Community spaces");
|
|
114
|
+
// The billing actions are untouched, so this is the entry point missing and
|
|
115
|
+
// not the whole card failing to draw.
|
|
116
|
+
expect(html).toContain("Change");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("draws nothing when the host site has no community route to send anyone to", () => {
|
|
120
|
+
// `onNavigateCommunity` is optional. A site that leaves it out gets no
|
|
121
|
+
// button, even with spaces in the cache, rather than a dead one.
|
|
122
|
+
const html = render({ spaces: [space()] });
|
|
123
|
+
|
|
124
|
+
expect(html).not.toContain("Community spaces");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("stays quiet while the spaces query has not answered yet", () => {
|
|
128
|
+
// First paint has no cached answer. Guessing "yes" here would flash a link
|
|
129
|
+
// and then withdraw it from members who never had access.
|
|
130
|
+
const html = render({ spaces: null, onNavigateCommunity: () => {} });
|
|
131
|
+
|
|
132
|
+
expect(html).not.toContain("Community spaces");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
4
|
+
import { BroadcastPassValidation } from "../broadcast/BroadcastPassValidation";
|
|
5
|
+
import { EndedBroadcast } from "../broadcast/EndedBroadcast";
|
|
6
|
+
import type { ILiveBroadcast } from "../../../types/models";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The paywall in front of a live broadcast, and the screen that replaces it once
|
|
10
|
+
* the stream is over.
|
|
11
|
+
*
|
|
12
|
+
* Rendered through `react-dom/server`, which needs no DOM. Effects never fire
|
|
13
|
+
* under `renderToStaticMarkup`, so the props are the only data source and
|
|
14
|
+
* nothing reaches the network. Both components are presentational by design
|
|
15
|
+
* (their data hooks live one level up in `BroadcastWatch`), which is exactly
|
|
16
|
+
* what makes this possible.
|
|
17
|
+
*
|
|
18
|
+
* These are the REAL components a Forge code site renders, and the client app's
|
|
19
|
+
* copies are twins driven by the same `broadcastState` rules, so what is
|
|
20
|
+
* asserted here about WHAT IS SAID holds on both rendering stacks.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
|
|
24
|
+
|
|
25
|
+
function broadcast(overrides: Partial<ILiveBroadcast> = {}): ILiveBroadcast {
|
|
26
|
+
return {
|
|
27
|
+
id: "b1",
|
|
28
|
+
title: "Sunday Session",
|
|
29
|
+
createdAt: "2026-08-01T00:00:00.000Z",
|
|
30
|
+
updatedAt: "2026-08-01T00:00:00.000Z",
|
|
31
|
+
profileId: "p1",
|
|
32
|
+
streamTemplateId: "st1",
|
|
33
|
+
egressId: "e1",
|
|
34
|
+
events: [],
|
|
35
|
+
event: { id: "ev1", title: "Sunday Session", description: "", isPaid: true },
|
|
36
|
+
liveUrl: "https://cdn.example.com/secret-playlist.m3u8",
|
|
37
|
+
thumbnailUrl: "https://cdn.example.com/thumb.jpg",
|
|
38
|
+
...overrides,
|
|
39
|
+
} as ILiveBroadcast;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const render = (node: React.ReactNode) =>
|
|
43
|
+
renderToStaticMarkup(<ForgeThemeProvider theme={theme}>{node}</ForgeThemeProvider>);
|
|
44
|
+
|
|
45
|
+
describe("BroadcastPassValidation", () => {
|
|
46
|
+
const gate = (props: Partial<React.ComponentProps<typeof BroadcastPassValidation>> = {}) =>
|
|
47
|
+
render(<BroadcastPassValidation broadcast={broadcast()} onValidate={() => {}} {...props} />);
|
|
48
|
+
|
|
49
|
+
it("REGRESSION: leaks NO playback url while the viewer is still outside the paywall", () => {
|
|
50
|
+
// The whole point of the gate. A page that renders the ticket box and the
|
|
51
|
+
// stream url in the same document has not gated anything: the url is two
|
|
52
|
+
// keystrokes away in the page source.
|
|
53
|
+
const html = gate();
|
|
54
|
+
expect(html).not.toContain("secret-playlist.m3u8");
|
|
55
|
+
expect(html).not.toContain("<video");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("asks for the ticket id, and says what one looks like", () => {
|
|
59
|
+
const html = gate();
|
|
60
|
+
expect(html).toContain("Enter your ticket ID to join the broadcast (TN-XXXXXXX)");
|
|
61
|
+
expect(html).toContain('data-testid="broadcast-ticket-code"');
|
|
62
|
+
expect(html).toContain("Join Broadcast");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("still shows the title and thumbnail, which are already public on the list", () => {
|
|
66
|
+
const html = gate();
|
|
67
|
+
expect(html).toContain("Sunday Session");
|
|
68
|
+
expect(html).toContain("thumb.jpg");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("REGRESSION: keeps Join disabled until something is typed", () => {
|
|
72
|
+
// An enabled button on an empty box sends a blank code, which the server
|
|
73
|
+
// refuses, and the viewer reads the refusal as "my ticket is not valid".
|
|
74
|
+
expect(gate()).toContain("disabled");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("says the code is in flight rather than leaving the button looking dead", () => {
|
|
78
|
+
const html = gate({ isValidating: true });
|
|
79
|
+
expect(html).toContain("Validating");
|
|
80
|
+
expect(html).toContain("disabled");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("shows a refusal to the viewer, and names no other event", () => {
|
|
84
|
+
const html = gate({ error: "That ticket is not valid for this broadcast." });
|
|
85
|
+
expect(html).toContain("That ticket is not valid for this broadcast.");
|
|
86
|
+
expect(html).toContain('role="alert"');
|
|
87
|
+
expect(html).toContain('aria-invalid="true"');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("offers a way to BUY when the caller supplies one, so the gate is not a dead end", () => {
|
|
91
|
+
const html = gate({ buyTickets: <div data-testid="buy">Buy tickets</div> });
|
|
92
|
+
expect(html).toContain("OR");
|
|
93
|
+
expect(html).toContain('data-testid="buy"');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("shows no OR divider when there is nothing to buy", () => {
|
|
97
|
+
expect(gate()).not.toContain(">OR<");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("EndedBroadcast", () => {
|
|
102
|
+
const html = () => render(<EndedBroadcast broadcast={broadcast({ endedAt: "2026-08-18T11:00:00.000Z" })} />);
|
|
103
|
+
|
|
104
|
+
it("says the broadcast ended, over the thumbnail the viewer recognises", () => {
|
|
105
|
+
expect(html()).toContain("Broadcast Ended");
|
|
106
|
+
expect(html()).toContain("Sunday Session");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("REGRESSION: keeps the two INSTRUCTIONS visible, not behind the ? ", () => {
|
|
110
|
+
// A host on a bad line drops and comes back. A viewer who is told nothing
|
|
111
|
+
// assumes the show is over and leaves, so what to DO stays on the page.
|
|
112
|
+
const markup = html();
|
|
113
|
+
expect(markup).toContain("keep refreshing this page");
|
|
114
|
+
expect(markup).toContain("Close the broadcast page");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("puts the EXPLANATION behind the ?, per the house design law", () => {
|
|
118
|
+
const markup = html();
|
|
119
|
+
// The disclosure exists, named after what it explains rather than "help".
|
|
120
|
+
expect(markup).toContain('aria-label="Why did the broadcast end?"');
|
|
121
|
+
expect(markup).toContain('aria-expanded="false"');
|
|
122
|
+
// ...and its body is not on the page until it is opened.
|
|
123
|
+
expect(markup).not.toContain("unstable internet connection");
|
|
124
|
+
});
|
|
125
|
+
});
|