@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.
Files changed (44) hide show
  1. package/package.json +9 -3
  2. package/src/client/_tests/tenantHeaders.spec.ts +77 -0
  3. package/src/client/createForgeClient.ts +37 -0
  4. package/src/contexts/CartContext.tsx +17 -1
  5. package/src/contexts/_tests/CartContext.spec.tsx +36 -0
  6. package/src/data/queries/useFilmPlaybackSession.ts +224 -0
  7. package/src/data/queries/useFilms.ts +561 -0
  8. package/src/data/queries/useMyBookings.ts +9 -0
  9. package/src/i18n/_tests/translationKeys.spec.ts +15 -0
  10. package/src/i18n/de.json +140 -2
  11. package/src/i18n/en.json +140 -2
  12. package/src/index.ts +5 -0
  13. package/src/provider/ForgeAppProvider.tsx +10 -0
  14. package/src/provider/ForgeProvider.tsx +18 -3
  15. package/src/server/index.ts +76 -17
  16. package/src/ui/headless/film/FilmWatermark.tsx +180 -0
  17. package/src/ui/headless/film/_tests/filmRules.spec.ts +531 -0
  18. package/src/ui/headless/film/_tests/useStageFullscreen.spec.ts +167 -0
  19. package/src/ui/headless/film/index.ts +36 -0
  20. package/src/ui/headless/film/useFilmCatalog.ts +66 -0
  21. package/src/ui/headless/film/useFilmPlayback.ts +497 -0
  22. package/src/ui/headless/film/useFilmRentalFlow.ts +277 -0
  23. package/src/ui/headless/film/useStageFullscreen.ts +156 -0
  24. package/src/ui/headless/index.ts +6 -9
  25. package/src/ui/index.ts +43 -16
  26. package/src/ui/media/BookingCallScreen.tsx +33 -0
  27. package/src/ui/media/CallStage.tsx +212 -63
  28. package/src/ui/media/CallWindowNotice.tsx +94 -0
  29. package/src/ui/media/_tests/CallStage.spec.tsx +265 -19
  30. package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
  31. package/src/ui/media/_tests/bookingSession.spec.tsx +48 -0
  32. package/src/ui/media/_tests/callState.spec.ts +63 -16
  33. package/src/ui/media/bookingSession.tsx +45 -57
  34. package/src/ui/media/bookingWindow.ts +81 -0
  35. package/src/ui/media/callState.ts +42 -23
  36. package/src/ui/media/index.ts +9 -0
  37. package/src/ui/styled/AccountDashboard.tsx +97 -5
  38. package/src/ui/styled/FilmCatalog.tsx +278 -0
  39. package/src/ui/styled/FilmDetail.tsx +661 -0
  40. package/src/ui/styled/FilmLibrary.tsx +254 -0
  41. package/src/ui/styled/FilmWatch.tsx +701 -0
  42. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +194 -0
  43. package/src/ui/styled/forge-utilities.css +835 -0
  44. 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
+ });