@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.
Files changed (51) hide show
  1. package/package.json +6 -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/PublicAuthContext.tsx +34 -5
  6. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  7. package/src/data/queries/useBroadcasts.ts +151 -0
  8. package/src/data/queries/useMyBookings.ts +9 -1
  9. package/src/i18n/de.json +59 -0
  10. package/src/i18n/en.json +59 -0
  11. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  12. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  13. package/src/ui/format/membershipPwyw.ts +164 -0
  14. package/src/ui/format/pwyw.ts +37 -0
  15. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  16. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  17. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  18. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  19. package/src/ui/headless/index.ts +14 -0
  20. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  21. package/src/ui/index.ts +36 -0
  22. package/src/ui/media/CallHelpHint.tsx +87 -0
  23. package/src/ui/media/CallStage.tsx +542 -0
  24. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  25. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  26. package/src/ui/media/_tests/callState.spec.ts +452 -0
  27. package/src/ui/media/_tests/fakeNode.ts +178 -0
  28. package/src/ui/media/bookingSession.tsx +194 -0
  29. package/src/ui/media/callState.ts +341 -0
  30. package/src/ui/media/index.ts +135 -0
  31. package/src/ui/styled/AccountDashboard.tsx +92 -3
  32. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  33. package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
  34. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  35. package/src/ui/styled/LoginForm.tsx +10 -0
  36. package/src/ui/styled/MembershipCheckout.tsx +318 -45
  37. package/src/ui/styled/MembershipTiers.tsx +10 -3
  38. package/src/ui/styled/ResetPasswordForm.tsx +5 -0
  39. package/src/ui/styled/SignupForm.tsx +5 -0
  40. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  41. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  42. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  43. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  44. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  45. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  46. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  47. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  48. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  49. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  50. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  51. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
@@ -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
+ });
@@ -0,0 +1,364 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
3
+ import { render, screen, waitFor, fireEvent, cleanup } from "@testing-library/react";
4
+ import axios, { type AxiosAdapter, type InternalAxiosRequestConfig } from "axios";
5
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
+ import { ForgeClientProvider } from "../../../provider/ForgeProvider";
7
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
8
+ import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
9
+ import { Currency, type MembershipTier, type PublicAuthUser } from "../../../types/models";
10
+ import { MembershipCheckout } from "../MembershipCheckout";
11
+
12
+ /**
13
+ * Membership signup, end to end, over the REAL query/mutation layer.
14
+ *
15
+ * The two things asserted hardest are the two that cost money:
16
+ *
17
+ * 1. **What is SENT.** Every request is captured off the axios adapter, so
18
+ * `amount` is checked as a number on the wire rather than as a number in a
19
+ * component's state. A pay-what-you-want box that renders "25" and posts 0
20
+ * passes any assertion made on the screen alone, and that is exactly the
21
+ * defect this file exists for.
22
+ * 2. **Which currency that number is in.** The fan's chosen display currency
23
+ * converts the totals they READ and must never touch the figure they TYPE:
24
+ * the tier rows and the API are both in the tenant's settlement currency.
25
+ *
26
+ * jsdom, because the amount box is seeded by an effect and the whole flow is
27
+ * clicks. The adapter is swapped rather than the modules mocked, so
28
+ * `useCreateSubscription`, `useGetMembershipTiers`, `PublicAuthProvider` and the
29
+ * hook all stay in the path: a rename of an endpoint or a payload field fails
30
+ * here.
31
+ */
32
+
33
+ const PROFILE_ID = "profile-1";
34
+
35
+ const tier = (overrides: Partial<MembershipTier> = {}): MembershipTier => ({
36
+ id: "tier-1",
37
+ name: "Inner Circle",
38
+ description: "The good stuff",
39
+ payWhatYouWant: false,
40
+ benefits: [],
41
+ ...overrides,
42
+ });
43
+
44
+ const member: PublicAuthUser = {
45
+ id: "account-1",
46
+ email: "fan@example.com",
47
+ firstName: "Fan",
48
+ lastName: "Person",
49
+ kind: "fan",
50
+ status: "active",
51
+ createdAt: "2026-01-01T00:00:00.000Z",
52
+ updatedAt: "2026-01-01T00:00:00.000Z",
53
+ };
54
+
55
+ /** Every request the component made, in order. */
56
+ let sent: { method: string; url: string; body: Record<string, unknown> | null }[] = [];
57
+ let realAdapter: AxiosAdapter | undefined;
58
+
59
+ /**
60
+ * The tenant settles in USD; this visitor reads prices in NGN at 1500 to the
61
+ * dollar. Any figure that has been through the rate is instantly recognisable.
62
+ */
63
+ const currencies = {
64
+ supportedCurrencies: [Currency.USD, Currency.NGN],
65
+ userCurrency: Currency.NGN,
66
+ exchangeRates: { [Currency.USD]: { [Currency.NGN]: 1500 } },
67
+ };
68
+
69
+ function installAdapter(tiers: MembershipTier[]) {
70
+ sent = [];
71
+ const adapter: AxiosAdapter = async (config: InternalAxiosRequestConfig) => {
72
+ const url = config.url ?? "";
73
+ const body = config.data ? (JSON.parse(config.data as string) as Record<string, unknown>) : null;
74
+ sent.push({ method: (config.method ?? "get").toLowerCase(), url, body });
75
+
76
+ const reply = (data: unknown) =>
77
+ Promise.resolve({ data, status: 200, statusText: "OK", headers: {}, config } as never);
78
+
79
+ if (url.includes("/public/membership-tiers")) return reply(tiers);
80
+ if (url.includes("/public/websites/site-config")) return reply({ currency: "USD" });
81
+ if (url.includes("/public/currencies")) return reply(currencies);
82
+ if (url.includes("/public/payments/subscriptions/free")) return reply({ membershipId: "m-1" });
83
+ if (url.includes("/public/payments/subscriptions")) {
84
+ return reply({ membershipId: "m-1", subscriptionId: "s-1", clientSecret: "pi_secret", requiresPayment: true });
85
+ }
86
+ return reply({});
87
+ };
88
+ realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
89
+ axios.defaults.adapter = adapter;
90
+ }
91
+
92
+ function mount(opts: { tiers: MembershipTier[]; user?: PublicAuthUser; initialTierId?: string }) {
93
+ installAdapter(opts.tiers);
94
+ // `"initialTierId" in opts` rather than `??`, so a test can ask for NO
95
+ // preselected tier (the grid) without the default quietly putting one back.
96
+ const initialTierId = "initialTierId" in opts ? opts.initialTierId : "tier-1";
97
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
98
+ return render(
99
+ <QueryClientProvider client={queryClient}>
100
+ <ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
101
+ <PublicAuthContext.Provider
102
+ value={
103
+ {
104
+ user: opts.user ?? member,
105
+ isInitialized: true,
106
+ isAuthenticated: true,
107
+ currencies,
108
+ userSelectedCurrency: Currency.NGN,
109
+ } as never
110
+ }
111
+ >
112
+ <ForgeThemeProvider
113
+ theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
114
+ >
115
+ <MembershipCheckout initialTierId={initialTierId} />
116
+ </ForgeThemeProvider>
117
+ </PublicAuthContext.Provider>
118
+ </ForgeClientProvider>
119
+ </QueryClientProvider>,
120
+ );
121
+ }
122
+
123
+ const amountBox = () => screen.getByRole("spinbutton") as HTMLInputElement;
124
+ const subscribeButton = () =>
125
+ screen.getByRole("button", { name: /Subscribe|Confirm change|Processing/i }) as HTMLButtonElement;
126
+ const subscriptionPosts = () =>
127
+ sent.filter((r) => r.method === "post" && r.url.includes("/public/payments/subscriptions"));
128
+
129
+ beforeEach(() => {
130
+ sent = [];
131
+ });
132
+
133
+ afterEach(() => {
134
+ cleanup();
135
+ if (realAdapter) axios.defaults.adapter = realAdapter;
136
+ });
137
+
138
+ describe("pay-what-you-want: the figure that is sent", () => {
139
+ it("REGRESSION: sends the tier's floor when the fan touches nothing, never 0", async () => {
140
+ // The defect: `customAmount` initialised to 0 and nothing seeded it from the
141
+ // tier, so the first press of Subscribe posted `amount: 0`. The server takes
142
+ // the amount as a positive number and refuses that outright, so a
143
+ // pay-what-you-want tier could not be bought at all.
144
+ mount({ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25 })] });
145
+
146
+ await waitFor(() => expect(amountBox()).toBeTruthy());
147
+ expect(amountBox().value).toBe("37500");
148
+
149
+ fireEvent.click(subscribeButton());
150
+
151
+ await waitFor(() => expect(subscriptionPosts()).toHaveLength(1));
152
+ expect(subscriptionPosts()[0].body).toMatchObject({
153
+ amount: 25,
154
+ billingCycle: "month",
155
+ membershipTierId: "tier-1",
156
+ profileId: PROFILE_ID,
157
+ });
158
+ });
159
+
160
+ it("sends a generous figure, converted from what was typed", async () => {
161
+ // The fan types naira because that is what the page is priced in; the API
162
+ // is charged in the settlement currency.
163
+ mount({ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25 })] });
164
+
165
+ await waitFor(() => expect(amountBox()).toBeTruthy());
166
+ fireEvent.change(amountBox(), { target: { value: "60000" } });
167
+ fireEvent.click(subscribeButton());
168
+
169
+ await waitFor(() => expect(subscriptionPosts()).toHaveLength(1));
170
+ // 60,000 naira at 1500 to the dollar.
171
+ expect(subscriptionPosts()[0].body).toMatchObject({ amount: 40 });
172
+ });
173
+
174
+ it("REGRESSION: switching to yearly re-seeds the box to the YEARLY floor and sends it", async () => {
175
+ // The floor belongs to the cycle. Carrying the monthly minimum onto a yearly
176
+ // subscription sells a whole year for a month's money.
177
+ mount({
178
+ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 5, payWhatYouWantYearlyMinimum: 50 })],
179
+ });
180
+
181
+ await waitFor(() => expect(amountBox().value).toBe("7500"));
182
+ const [monthly, yearly] = screen.getAllByRole("radio") as HTMLInputElement[];
183
+ expect(monthly.checked).toBe(true);
184
+ fireEvent.click(yearly);
185
+ // 50 USD, shown in the currency the fan is reading.
186
+ expect(amountBox().value).toBe("75000");
187
+
188
+ fireEvent.click(subscribeButton());
189
+
190
+ await waitFor(() => expect(subscriptionPosts()).toHaveLength(1));
191
+ expect(subscriptionPosts()[0].body).toMatchObject({ amount: 50, billingCycle: "year" });
192
+ });
193
+
194
+ it("refuses a figure under the floor beside the field, and sends nothing", async () => {
195
+ mount({ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25 })] });
196
+
197
+ await waitFor(() => expect(amountBox()).toBeTruthy());
198
+ fireEvent.change(amountBox(), { target: { value: "3" } });
199
+ fireEvent.click(subscribeButton());
200
+
201
+ expect(await screen.findByRole("alert")).toBeTruthy();
202
+ expect(subscriptionPosts()).toHaveLength(0);
203
+ });
204
+
205
+ it("refuses a cleared box, which is the zero the server will not take", async () => {
206
+ mount({ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25 })] });
207
+
208
+ await waitFor(() => expect(amountBox()).toBeTruthy());
209
+ fireEvent.change(amountBox(), { target: { value: "" } });
210
+ fireEvent.click(subscribeButton());
211
+
212
+ expect(await screen.findByRole("alert")).toBeTruthy();
213
+ expect(subscriptionPosts()).toHaveLength(0);
214
+ });
215
+ });
216
+
217
+ describe("currency: what converts and what does not", () => {
218
+ it("prints the pay-what-you-want floor in the VISITOR's currency", async () => {
219
+ // The whole screen is priced in naira, so the box is too. Pricing a tier in
220
+ // one currency and asking for the amount in another makes a fan do the
221
+ // arithmetic to buy something, and they will get it wrong.
222
+ mount({ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25 })] });
223
+
224
+ const label = await screen.findByText(/How much would you like to pay/i);
225
+ // 25 USD at 1500 to the dollar.
226
+ expect(label.textContent).toContain("₦37,500");
227
+ expect(label.textContent).not.toContain("$25");
228
+ });
229
+
230
+ it("seeds the box with the floor, in the currency the fan is reading", async () => {
231
+ mount({ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25 })] });
232
+
233
+ const input = (await screen.findByLabelText(/How much would you like to pay/i)) as HTMLInputElement;
234
+ expect(input.value).toBe("37500");
235
+ });
236
+
237
+ it("🔴 REGRESSION: sends a WHOLE settlement unit, never a raw quotient", async () => {
238
+ // Every membership price column is an `integer`. A raw quotient reached
239
+ // `ProfilePaymentPrice.findOne({ amount })` and Postgres answered
240
+ // `invalid input syntax for type integer: "426.76681461249575"` - a 500 on
241
+ // the subscribe button, from a number the fan never saw.
242
+ mount({ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25 })] });
243
+
244
+ const input = (await screen.findByLabelText(/How much would you like to pay/i)) as HTMLInputElement;
245
+ // 40,001 naira at 1500 is 26.667 dollars, which does not divide evenly.
246
+ fireEvent.change(input, { target: { value: "40001" } });
247
+
248
+ await waitFor(() => expect(subscribeButton()).toBeTruthy());
249
+ fireEvent.click(subscribeButton());
250
+
251
+ await waitFor(() => expect(subscriptionPosts()).toHaveLength(1));
252
+ const sent = (subscriptionPosts()[0].body as { amount: number }).amount;
253
+ expect(Number.isInteger(sent)).toBe(true);
254
+ expect(sent).toBe(27);
255
+ });
256
+
257
+ it("🔴 REGRESSION: converts what the fan TYPES back to the settlement currency", async () => {
258
+ // The half that stops the disaster. The API charges in USD, so a fan typing
259
+ // 37,500 meaning naira must be billed 25 dollars and not 37,500 of them.
260
+ mount({ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25 })] });
261
+
262
+ const input = (await screen.findByLabelText(/How much would you like to pay/i)) as HTMLInputElement;
263
+ fireEvent.change(input, { target: { value: "75000" } });
264
+
265
+ await waitFor(() => expect(subscribeButton()).toBeTruthy());
266
+ fireEvent.click(subscribeButton());
267
+
268
+ await waitFor(() => expect(subscriptionPosts()).toHaveLength(1));
269
+ // 75,000 naira at 1500 to the dollar is 50 dollars.
270
+ expect(subscriptionPosts()[0].body).toMatchObject({ amount: 50 });
271
+ });
272
+
273
+ it("converts the total the fan only reads", async () => {
274
+ mount({ tiers: [tier({ payWhatYouWant: true, payWhatYouWantMinimum: 25 })] });
275
+
276
+ // 25 USD at 1500 to the dollar.
277
+ expect(await screen.findByText("₦37,500")).toBeTruthy();
278
+ });
279
+
280
+ it("sends the settlement figure even while the screen reads naira", async () => {
281
+ mount({ tiers: [tier({ priceMonthly: 10 })] });
282
+
283
+ await waitFor(() => expect(subscribeButton()).toBeTruthy());
284
+ fireEvent.click(subscribeButton());
285
+
286
+ await waitFor(() => expect(subscriptionPosts()).toHaveLength(1));
287
+ expect(subscriptionPosts()[0].body).toMatchObject({ amount: 10 });
288
+ });
289
+ });
290
+
291
+ describe("billing cycles a tier actually sells", () => {
292
+ it("REGRESSION: buys a YEARLY-ONLY tier as a paid subscription, not as a free one", async () => {
293
+ // The defect: the cycle defaulted to the literal "month", the tier had no
294
+ // monthly price, so it was read as free and activated through
295
+ // `/subscriptions/free`. The fan got the tier and the artist got nothing.
296
+ mount({ tiers: [tier({ priceYearly: 100 })] });
297
+
298
+ await waitFor(() => expect(subscribeButton()).toBeTruthy());
299
+ fireEvent.click(subscribeButton());
300
+
301
+ await waitFor(() => expect(subscriptionPosts()).toHaveLength(1));
302
+ const post = subscriptionPosts()[0];
303
+ expect(post.url).not.toContain("/free");
304
+ expect(post.body).toMatchObject({ amount: 100, billingCycle: "year" });
305
+ });
306
+
307
+ it("offers no cycle choice when the tier sells only one", async () => {
308
+ mount({ tiers: [tier({ priceMonthly: 10 })] });
309
+
310
+ await waitFor(() => expect(subscribeButton()).toBeTruthy());
311
+ expect(screen.queryByRole("radio")).toBeNull();
312
+ });
313
+
314
+ it("activates a genuinely free tier through the free endpoint, with no amount", async () => {
315
+ mount({ tiers: [tier({ name: "Free Circle" })] });
316
+
317
+ await waitFor(() => expect(subscribeButton()).toBeTruthy());
318
+ fireEvent.click(subscribeButton());
319
+
320
+ await waitFor(() => expect(subscriptionPosts()).toHaveLength(1));
321
+ const post = subscriptionPosts()[0];
322
+ expect(post.url).toContain("/subscriptions/free");
323
+ expect(post.body).not.toHaveProperty("amount");
324
+ });
325
+ });
326
+
327
+ describe("the tier grid, when no tier was chosen up front", () => {
328
+ it("does not offer the tier the member is already on", async () => {
329
+ const onTier = { ...member, membership: { membershipTierId: "tier-1" } } as unknown as PublicAuthUser;
330
+ mount({
331
+ tiers: [tier({ id: "tier-1", name: "Inner Circle", priceMonthly: 10 }), tier({ id: "tier-2", name: "Backstage", priceMonthly: 20 })],
332
+ user: onTier,
333
+ initialTierId: undefined,
334
+ });
335
+
336
+ expect(await screen.findByText("Current plan")).toBeTruthy();
337
+ // One button per selectable tier: the current one offers a sentence instead.
338
+ expect(screen.getAllByRole("button", { name: "Select this tier" })).toHaveLength(1);
339
+ });
340
+
341
+ it("REGRESSION: advertises a yearly-only pay-what-you-want tier on its yearly floor", async () => {
342
+ // Reading the monthly minimum unconditionally printed "from ₦0/mo", which
343
+ // reads as free and is the opposite of what the tier costs.
344
+ mount({
345
+ tiers: [tier({ payWhatYouWant: true, payWhatYouWantYearlyMinimum: 60 })],
346
+ initialTierId: undefined,
347
+ });
348
+
349
+ const label = await screen.findByText(/Pay what you want from/i);
350
+ // 60 USD at 1500 to the dollar, advertised per year.
351
+ expect(label.textContent).toContain("₦90,000");
352
+ expect(label.textContent).toContain("/yr");
353
+ });
354
+
355
+ it("calls a change a change for a member with a live subscription", async () => {
356
+ const paying = {
357
+ ...member,
358
+ membership: { membershipTierId: "tier-1", status: "active", paymentProviderSubscriptionId: "sub_123" },
359
+ } as unknown as PublicAuthUser;
360
+ mount({ tiers: [tier({ id: "tier-2", name: "Backstage", priceMonthly: 20 })], user: paying, initialTierId: "tier-2" });
361
+
362
+ expect(await screen.findByRole("button", { name: "Confirm change" })).toBeTruthy();
363
+ });
364
+ });