@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.
Files changed (58) hide show
  1. package/package.json +11 -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/contexts/CartContext.tsx +17 -1
  6. package/src/contexts/PublicAuthContext.tsx +34 -5
  7. package/src/contexts/_tests/CartContext.spec.tsx +36 -0
  8. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  9. package/src/data/queries/useBroadcasts.ts +151 -0
  10. package/src/data/queries/useMyBookings.ts +18 -1
  11. package/src/i18n/_tests/translationKeys.spec.ts +15 -0
  12. package/src/i18n/de.json +97 -0
  13. package/src/i18n/en.json +97 -0
  14. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  15. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  16. package/src/ui/format/membershipPwyw.ts +164 -0
  17. package/src/ui/format/pwyw.ts +37 -0
  18. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  19. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  20. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  21. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  22. package/src/ui/headless/index.ts +14 -0
  23. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  24. package/src/ui/index.ts +61 -0
  25. package/src/ui/media/BookingCallScreen.tsx +33 -0
  26. package/src/ui/media/CallHelpHint.tsx +87 -0
  27. package/src/ui/media/CallStage.tsx +633 -0
  28. package/src/ui/media/_tests/CallStage.spec.tsx +931 -0
  29. package/src/ui/media/_tests/bookingSession.spec.tsx +227 -0
  30. package/src/ui/media/_tests/callState.spec.ts +499 -0
  31. package/src/ui/media/_tests/fakeNode.ts +178 -0
  32. package/src/ui/media/bookingSession.tsx +182 -0
  33. package/src/ui/media/bookingWindow.ts +81 -0
  34. package/src/ui/media/callState.ts +360 -0
  35. package/src/ui/media/index.ts +135 -0
  36. package/src/ui/styled/AccountDashboard.tsx +193 -4
  37. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  38. package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
  39. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  40. package/src/ui/styled/LoginForm.tsx +10 -0
  41. package/src/ui/styled/MembershipCheckout.tsx +318 -45
  42. package/src/ui/styled/MembershipTiers.tsx +10 -3
  43. package/src/ui/styled/ResetPasswordForm.tsx +5 -0
  44. package/src/ui/styled/SignupForm.tsx +5 -0
  45. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +166 -0
  46. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  47. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  48. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  49. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  50. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  51. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  52. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  53. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  54. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  55. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  56. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  57. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
  58. 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
+ });