@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,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
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { useState, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import type { ILiveBroadcast } from "../../../types/models";
|
|
3
|
+
import { useForgeT } from "../../../i18n";
|
|
4
|
+
import { useForgeTheme, type ForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
5
|
+
import { buttonStyle } from "../Button";
|
|
6
|
+
|
|
7
|
+
/** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
|
|
8
|
+
const a = (hex: string, alpha: number) => hex + Math.round(alpha * 255).toString(16).padStart(2, "0");
|
|
9
|
+
|
|
10
|
+
const inputStyle = (t: ForgeTheme): CSSProperties => ({
|
|
11
|
+
width: "100%",
|
|
12
|
+
padding: 10,
|
|
13
|
+
border: `1px solid ${a(t.colors.primary, 0.25)}`,
|
|
14
|
+
borderRadius: t.cornerRadius,
|
|
15
|
+
background: t.colors.background,
|
|
16
|
+
color: t.colors.text,
|
|
17
|
+
fontFamily: t.fontFamily,
|
|
18
|
+
boxSizing: "border-box",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export interface BroadcastPassValidationProps {
|
|
22
|
+
broadcast: ILiveBroadcast;
|
|
23
|
+
/** Present a ticket code. Rejection comes back through `error`, not a throw the caller sees. */
|
|
24
|
+
onValidate: (ticketCode: string) => void | Promise<unknown>;
|
|
25
|
+
/** True while the code is in flight. */
|
|
26
|
+
isValidating?: boolean;
|
|
27
|
+
/** Why the last code was refused. Stays visible: it is a validation error. */
|
|
28
|
+
error?: string | null;
|
|
29
|
+
/**
|
|
30
|
+
* The buy-a-ticket panel, supplied by the caller so this component stays free
|
|
31
|
+
* of data hooks and can be rendered (and asserted on) without a provider tree.
|
|
32
|
+
*/
|
|
33
|
+
buyTickets?: ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* THE PAYWALL. A broadcast with a linked event does not play a frame until a
|
|
38
|
+
* viewer has presented a ticket code that the server accepted.
|
|
39
|
+
*
|
|
40
|
+
* Everything a viewer can see before that point is deliberately worthless: the
|
|
41
|
+
* title and the thumbnail, which are already public on the list page. The video
|
|
42
|
+
* element is not mounted, the playback URL is not in the markup, and the chat
|
|
43
|
+
* socket is not opened, because the component that does all three is not
|
|
44
|
+
* rendered until `useBroadcastWatch` reports a validated pass.
|
|
45
|
+
*
|
|
46
|
+
* The refusal message says only that the code was not accepted. It never says
|
|
47
|
+
* which event a code IS valid for, which would turn this box into a way to test
|
|
48
|
+
* ticket ids against every broadcast on the site.
|
|
49
|
+
*/
|
|
50
|
+
export function BroadcastPassValidation({
|
|
51
|
+
broadcast,
|
|
52
|
+
onValidate,
|
|
53
|
+
isValidating,
|
|
54
|
+
error,
|
|
55
|
+
buyTickets,
|
|
56
|
+
}: BroadcastPassValidationProps) {
|
|
57
|
+
const t = useForgeT();
|
|
58
|
+
const theme = useForgeTheme();
|
|
59
|
+
const [ticketCode, setTicketCode] = useState("");
|
|
60
|
+
const thumb = broadcast.thumbnailUrl || broadcast.generatedThumbnailUrl;
|
|
61
|
+
|
|
62
|
+
// Trimmed, because a ticket code is nearly always pasted and a pasted code
|
|
63
|
+
// nearly always brings a trailing space with it.
|
|
64
|
+
const trimmed = ticketCode.trim();
|
|
65
|
+
const canSubmit = !!trimmed && !isValidating;
|
|
66
|
+
|
|
67
|
+
const submit = () => {
|
|
68
|
+
if (!canSubmit || !broadcast.id) return;
|
|
69
|
+
void onValidate(trimmed);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
data-testid="broadcast-pass-validation"
|
|
75
|
+
style={{
|
|
76
|
+
display: "flex",
|
|
77
|
+
flexDirection: "column",
|
|
78
|
+
gap: 16,
|
|
79
|
+
alignItems: "center",
|
|
80
|
+
padding: "0 16px",
|
|
81
|
+
color: theme.colors.text,
|
|
82
|
+
fontFamily: theme.fontFamily,
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
<div
|
|
86
|
+
style={{
|
|
87
|
+
marginTop: 16,
|
|
88
|
+
maxWidth: 500,
|
|
89
|
+
width: "100%",
|
|
90
|
+
marginInline: "auto",
|
|
91
|
+
backgroundColor: theme.colors.background,
|
|
92
|
+
borderRadius: theme.cornerRadius,
|
|
93
|
+
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
|
|
94
|
+
border: `1px solid ${theme.colors.primary}30`,
|
|
95
|
+
overflow: "hidden",
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
<div
|
|
99
|
+
style={{
|
|
100
|
+
aspectRatio: "16/9",
|
|
101
|
+
position: "relative",
|
|
102
|
+
display: "flex",
|
|
103
|
+
alignItems: "center",
|
|
104
|
+
justifyContent: "center",
|
|
105
|
+
background: `linear-gradient(135deg, ${theme.colors.primary}20, ${theme.colors.primary}10)`,
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
{thumb && (
|
|
109
|
+
<img
|
|
110
|
+
src={thumb}
|
|
111
|
+
alt={broadcast.title}
|
|
112
|
+
style={{ width: "100%", height: "100%", objectFit: "cover", position: "absolute", top: 0, left: 0 }}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
<div style={{ padding: 16 }}>
|
|
117
|
+
<h3 style={{ fontWeight: 600 }}>{broadcast.title}</h3>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<div
|
|
122
|
+
style={{
|
|
123
|
+
marginTop: 16,
|
|
124
|
+
maxWidth: 500,
|
|
125
|
+
width: "100%",
|
|
126
|
+
marginInline: "auto",
|
|
127
|
+
display: "flex",
|
|
128
|
+
flexDirection: "column",
|
|
129
|
+
gap: 16,
|
|
130
|
+
alignItems: "center",
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
<label htmlFor="broadcast-ticket-code" style={{ textAlign: "center" }}>
|
|
134
|
+
{t("forge.broadcast_pass_validation.prompt")}
|
|
135
|
+
</label>
|
|
136
|
+
<input
|
|
137
|
+
id="broadcast-ticket-code"
|
|
138
|
+
data-testid="broadcast-ticket-code"
|
|
139
|
+
placeholder={t("forge.broadcast_pass_validation.input_placeholder")}
|
|
140
|
+
value={ticketCode}
|
|
141
|
+
onChange={(e) => setTicketCode(e.target.value)}
|
|
142
|
+
onKeyDown={(e) => {
|
|
143
|
+
if (e.key !== "Enter") return;
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
submit();
|
|
146
|
+
}}
|
|
147
|
+
aria-invalid={error ? true : undefined}
|
|
148
|
+
aria-describedby={error ? "broadcast-ticket-code-error" : undefined}
|
|
149
|
+
style={inputStyle(theme)}
|
|
150
|
+
/>
|
|
151
|
+
{error && (
|
|
152
|
+
<p
|
|
153
|
+
id="broadcast-ticket-code-error"
|
|
154
|
+
data-testid="broadcast-pass-error"
|
|
155
|
+
role="alert"
|
|
156
|
+
// Refusal is the one place a non-theme colour is right: red is the
|
|
157
|
+
// convention for "this input is wrong", and the artist's primary
|
|
158
|
+
// could be any hue including a green.
|
|
159
|
+
style={{ color: "#ef4444", fontSize: 13, margin: 0, alignSelf: "flex-start" }}
|
|
160
|
+
>
|
|
161
|
+
{error}
|
|
162
|
+
</p>
|
|
163
|
+
)}
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
data-testid="broadcast-pass-submit"
|
|
167
|
+
onClick={submit}
|
|
168
|
+
disabled={!canSubmit}
|
|
169
|
+
style={buttonStyle(theme, { fullWidth: true, disabled: !canSubmit })}
|
|
170
|
+
>
|
|
171
|
+
{isValidating
|
|
172
|
+
? t("forge.broadcast_pass_validation.validating")
|
|
173
|
+
: t("forge.broadcast_pass_validation.join")}
|
|
174
|
+
</button>
|
|
175
|
+
|
|
176
|
+
{buyTickets && (
|
|
177
|
+
<>
|
|
178
|
+
<p style={{ marginBlock: 16 }}>{t("forge.broadcast_pass_validation.or")}</p>
|
|
179
|
+
{buyTickets}
|
|
180
|
+
</>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export default BroadcastPassValidation;
|