@tribe-nest/forge 3.31.0 → 3.35.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 +9 -3
- package/src/client/_tests/tenantHeaders.spec.ts +77 -0
- package/src/client/createForgeClient.ts +37 -0
- package/src/contexts/CartContext.tsx +17 -1
- package/src/contexts/_tests/CartContext.spec.tsx +36 -0
- package/src/data/queries/useFilmPlaybackSession.ts +224 -0
- package/src/data/queries/useFilms.ts +561 -0
- package/src/data/queries/useMyBookings.ts +9 -0
- package/src/i18n/_tests/translationKeys.spec.ts +15 -0
- package/src/i18n/de.json +140 -2
- package/src/i18n/en.json +140 -2
- package/src/index.ts +5 -0
- package/src/provider/ForgeAppProvider.tsx +10 -0
- package/src/provider/ForgeProvider.tsx +18 -3
- package/src/server/index.ts +76 -17
- package/src/ui/headless/film/FilmWatermark.tsx +180 -0
- package/src/ui/headless/film/_tests/filmRules.spec.ts +531 -0
- package/src/ui/headless/film/_tests/useStageFullscreen.spec.ts +167 -0
- package/src/ui/headless/film/index.ts +36 -0
- package/src/ui/headless/film/useFilmCatalog.ts +66 -0
- package/src/ui/headless/film/useFilmPlayback.ts +497 -0
- package/src/ui/headless/film/useFilmRentalFlow.ts +277 -0
- package/src/ui/headless/film/useStageFullscreen.ts +156 -0
- package/src/ui/headless/index.ts +6 -9
- package/src/ui/index.ts +43 -16
- package/src/ui/media/BookingCallScreen.tsx +33 -0
- package/src/ui/media/CallStage.tsx +212 -63
- package/src/ui/media/CallWindowNotice.tsx +94 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +265 -19
- package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +48 -0
- package/src/ui/media/_tests/callState.spec.ts +63 -16
- package/src/ui/media/bookingSession.tsx +45 -57
- package/src/ui/media/bookingWindow.ts +81 -0
- package/src/ui/media/callState.ts +42 -23
- package/src/ui/media/index.ts +9 -0
- package/src/ui/styled/AccountDashboard.tsx +97 -5
- package/src/ui/styled/FilmCatalog.tsx +278 -0
- package/src/ui/styled/FilmDetail.tsx +661 -0
- package/src/ui/styled/FilmLibrary.tsx +254 -0
- package/src/ui/styled/FilmWatch.tsx +701 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +194 -0
- package/src/ui/styled/forge-utilities.css +835 -0
- package/src/utils/structuredData.ts +88 -17
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
const HOUR = 60 * MINUTE;
|
|
31
|
+
|
|
32
|
+
const member = {
|
|
33
|
+
id: ACCOUNT_ID,
|
|
34
|
+
email: "client@example.com",
|
|
35
|
+
firstName: "Client",
|
|
36
|
+
lastName: "Person",
|
|
37
|
+
kind: "fan",
|
|
38
|
+
status: "active",
|
|
39
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
40
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
41
|
+
membership: null,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const booking = (overrides: Partial<MyBooking> = {}): MyBooking => {
|
|
45
|
+
const start = new Date(Date.now() + 5 * MINUTE);
|
|
46
|
+
const end = new Date(start.getTime() + 60 * MINUTE);
|
|
47
|
+
return {
|
|
48
|
+
id: "booking-1",
|
|
49
|
+
status: "confirmed",
|
|
50
|
+
email: "client@example.com",
|
|
51
|
+
firstName: "Client",
|
|
52
|
+
lastName: "Person",
|
|
53
|
+
totalAmount: 40,
|
|
54
|
+
totalAmountInChargedCurrency: null,
|
|
55
|
+
currency: "USD",
|
|
56
|
+
chargedCurrency: null,
|
|
57
|
+
refundState: null,
|
|
58
|
+
refundedAmountCents: null,
|
|
59
|
+
selfCancelledAt: null,
|
|
60
|
+
cancellationOutcome: null,
|
|
61
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
62
|
+
coachingBookingSlotId: "slot-1",
|
|
63
|
+
// The call's address, written when the booking was confirmed. A real
|
|
64
|
+
// confirmed video booking always carries one.
|
|
65
|
+
videoCallId: "call-1",
|
|
66
|
+
sessionStartTime: start.toISOString(),
|
|
67
|
+
sessionEndTime: end.toISOString(),
|
|
68
|
+
coachingProductId: "product-1",
|
|
69
|
+
coachingProductSlug: "video-session",
|
|
70
|
+
coachingProductTitle: "Video session",
|
|
71
|
+
coachingProductTimezone: null,
|
|
72
|
+
coachingProductDurationMinutes: 60,
|
|
73
|
+
cancellation: {
|
|
74
|
+
policy: null,
|
|
75
|
+
cutoffHours: null,
|
|
76
|
+
terms: null,
|
|
77
|
+
description: null,
|
|
78
|
+
canCancel: false,
|
|
79
|
+
reason: null,
|
|
80
|
+
deadline: null,
|
|
81
|
+
},
|
|
82
|
+
location: { type: "video" } as MyBooking["location"],
|
|
83
|
+
canReschedule: false,
|
|
84
|
+
rescheduleReason: null,
|
|
85
|
+
calendarUrl: null,
|
|
86
|
+
calendarWebcalUrl: null,
|
|
87
|
+
...overrides,
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
function render(rows: MyBooking[]): string {
|
|
92
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
93
|
+
queryClient.setQueryData(["my-bookings", ACCOUNT_ID, PROFILE_ID, 1, 20], {
|
|
94
|
+
data: rows,
|
|
95
|
+
total: rows.length,
|
|
96
|
+
page: 1,
|
|
97
|
+
limit: 20,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return renderToStaticMarkup(
|
|
101
|
+
<QueryClientProvider client={queryClient}>
|
|
102
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
|
|
103
|
+
<PublicAuthContext.Provider
|
|
104
|
+
value={{ user: member, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}
|
|
105
|
+
>
|
|
106
|
+
<ForgeThemeProvider
|
|
107
|
+
theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
|
|
108
|
+
>
|
|
109
|
+
<AccountDashboard tab="bookings" onNavigateMembership={() => {}} />
|
|
110
|
+
</ForgeThemeProvider>
|
|
111
|
+
</PublicAuthContext.Provider>
|
|
112
|
+
</ForgeClientProvider>
|
|
113
|
+
</QueryClientProvider>,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe("AccountDashboard: the way into a coaching video call", () => {
|
|
118
|
+
it("REGRESSION: offers Join for a video session whose room is open", () => {
|
|
119
|
+
// Starts in 5 minutes: inside the 15-minutes-early window.
|
|
120
|
+
const html = render([booking()]);
|
|
121
|
+
|
|
122
|
+
expect(html).toContain("Video session");
|
|
123
|
+
expect(html).toContain("Join video call");
|
|
124
|
+
// A LINK to the call's own page, at the same address the coach opens from
|
|
125
|
+
// the dashboard. It used to mount the room inline, between two rows of a
|
|
126
|
+
// list, which is not what a call looks like anywhere else.
|
|
127
|
+
expect(html).toContain('href="/i/video-calls/call-1"');
|
|
128
|
+
// The line still says where the session is; the control is in addition.
|
|
129
|
+
expect(html).toContain("Video call on this site");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("draws no control for a session with no call address, but still says it is a video call", () => {
|
|
133
|
+
/**
|
|
134
|
+
* Only reachable for a booking confirmed before calls became addressable,
|
|
135
|
+
* since a confirmation now writes the record. A dead button would be worse
|
|
136
|
+
* than none, and the row above still names the session as a video call.
|
|
137
|
+
*/
|
|
138
|
+
const html = render([booking({ videoCallId: null })]);
|
|
139
|
+
|
|
140
|
+
expect(html).toContain("Video call on this site");
|
|
141
|
+
expect(html).not.toContain("Join video call");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("still says when the room opens even without a call address", () => {
|
|
145
|
+
// The window copy is about the SESSION, so it must not depend on whether a
|
|
146
|
+
// room record exists. This is the branch my first attempt broke.
|
|
147
|
+
const html = render([booking({ videoCallId: null, sessionStartTime: new Date(Date.now() + 6 * HOUR).toISOString() })]);
|
|
148
|
+
|
|
149
|
+
expect(html).toMatch(/opens|Opens/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("says when the room opens, rather than offering a Join that would 409", () => {
|
|
153
|
+
const start = new Date(Date.now() + 3 * 60 * MINUTE);
|
|
154
|
+
const html = render([
|
|
155
|
+
booking({
|
|
156
|
+
sessionStartTime: start.toISOString(),
|
|
157
|
+
sessionEndTime: new Date(start.getTime() + 60 * MINUTE).toISOString(),
|
|
158
|
+
}),
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
expect(html).not.toContain("Join video call");
|
|
162
|
+
expect(html).toContain("the room opens");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("says the room has closed once the window is over", () => {
|
|
166
|
+
const start = new Date(Date.now() - 5 * 60 * MINUTE);
|
|
167
|
+
const html = render([
|
|
168
|
+
booking({
|
|
169
|
+
sessionStartTime: start.toISOString(),
|
|
170
|
+
sessionEndTime: new Date(start.getTime() + 60 * MINUTE).toISOString(),
|
|
171
|
+
}),
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
expect(html).not.toContain("Join video call");
|
|
175
|
+
expect(html).toContain("room has closed");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("draws nothing for a session that is not a video call", () => {
|
|
179
|
+
const html = render([
|
|
180
|
+
booking({ location: { type: "online", url: "https://meet.example.com/x" } as MyBooking["location"] }),
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
expect(html).not.toContain("Join video call");
|
|
184
|
+
expect(html).not.toContain("the room opens");
|
|
185
|
+
expect(html).toContain("https://meet.example.com/x");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("draws nothing for a cancelled session, whose room the server withholds", () => {
|
|
189
|
+
const html = render([booking({ status: "canceled", selfCancelledAt: "2026-01-02T00:00:00.000Z", location: null })]);
|
|
190
|
+
|
|
191
|
+
expect(html).not.toContain("Join video call");
|
|
192
|
+
expect(html).not.toContain("the room opens");
|
|
193
|
+
});
|
|
194
|
+
});
|