@tribe-nest/forge 3.53.0 → 3.57.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 +1 -1
- package/src/contexts/AppAuthContext.tsx +3 -2
- package/src/contexts/AudioPlayerContext.tsx +3 -2
- package/src/contexts/CartContext.tsx +3 -2
- package/src/contexts/PublicAuthContext.tsx +3 -2
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +26 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useMyTickets.ts +49 -0
- package/src/data/queries/usePaymentFlow.ts +8 -0
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +116 -1
- package/src/i18n/en.json +116 -1
- package/src/i18n/index.ts +3 -2
- package/src/index.ts +22 -0
- package/src/provider/ForgeAppProvider.tsx +8 -1
- package/src/provider/ForgeProvider.tsx +3 -2
- package/src/provider/SiteConfigProvider.tsx +3 -2
- package/src/runtime/RemotePage.tsx +110 -0
- package/src/runtime/hostRuntime.ts +84 -0
- package/src/runtime/pages.spec.ts +41 -0
- package/src/runtime/pages.ts +183 -0
- package/src/runtime/pagesClient.ts +127 -0
- package/src/runtime/registry.spec.ts +45 -0
- package/src/runtime/registry.ts +102 -0
- package/src/server/index.ts +74 -0
- package/src/server/jobs.ts +4 -1
- package/src/server/platformEvents.generated.ts +152 -0
- package/src/types/models.ts +185 -0
- package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
- package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
- package/src/ui/headless/event/ticketApproval.ts +109 -0
- package/src/ui/headless/event/useEventCheckout.ts +74 -4
- package/src/ui/headless/index.ts +34 -0
- package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
- package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
- package/src/ui/headless/membership/membershipApplication.ts +109 -0
- package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
- package/src/ui/headless/membership/membershipTrial.ts +53 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
- package/src/ui/index.ts +27 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +29 -0
- package/src/ui/payment/ForgeStripePayment.tsx +110 -5
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/shell/TribeNestApp.tsx +6 -0
- package/src/ui/styled/AccountDashboard.tsx +239 -15
- package/src/ui/styled/DocumentSigningPage.tsx +516 -0
- package/src/ui/styled/EventConfirmation.tsx +106 -9
- package/src/ui/styled/EventTickets.tsx +107 -13
- package/src/ui/styled/LoginForm.tsx +5 -3
- package/src/ui/styled/MembershipCheckout.tsx +596 -256
- package/src/ui/styled/MembershipTierCallout.tsx +17 -3
- package/src/ui/styled/MembershipTiers.tsx +148 -6
- package/src/ui/styled/ProductGrid.tsx +14 -2
- package/src/ui/styled/ReviewRequestPage.tsx +214 -0
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
- package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
- package/src/ui/styled/forge-utilities.css +310 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
- package/src/utils/ticketOrderOutcome.ts +57 -1
|
@@ -79,6 +79,24 @@ function installAdapter(tiers: MembershipTier[]) {
|
|
|
79
79
|
if (url.includes("/public/membership-tiers")) return reply(tiers);
|
|
80
80
|
if (url.includes("/public/websites/site-config")) return reply({ currency: "USD" });
|
|
81
81
|
if (url.includes("/public/currencies")) return reply(currencies);
|
|
82
|
+
if (url.includes("/public/membership-applications")) {
|
|
83
|
+
return reply({
|
|
84
|
+
application: {
|
|
85
|
+
id: "app-1",
|
|
86
|
+
profileId: PROFILE_ID,
|
|
87
|
+
membershipTierId: "tier-1",
|
|
88
|
+
accountId: "account-1",
|
|
89
|
+
status: "pending",
|
|
90
|
+
billingCycle: null,
|
|
91
|
+
amount: null,
|
|
92
|
+
currency: null,
|
|
93
|
+
message: null,
|
|
94
|
+
closedReason: null,
|
|
95
|
+
},
|
|
96
|
+
requiresCard: false,
|
|
97
|
+
clientSecret: null,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
82
100
|
if (url.includes("/public/payments/subscriptions/free")) return reply({ membershipId: "m-1" });
|
|
83
101
|
if (url.includes("/public/payments/subscriptions")) {
|
|
84
102
|
return reply({ membershipId: "m-1", subscriptionId: "s-1", clientSecret: "pi_secret", requiresPayment: true });
|
|
@@ -362,3 +380,91 @@ describe("the tier grid, when no tier was chosen up front", () => {
|
|
|
362
380
|
expect(await screen.findByRole("button", { name: "Confirm change" })).toBeTruthy();
|
|
363
381
|
});
|
|
364
382
|
});
|
|
383
|
+
|
|
384
|
+
describe("free trials", () => {
|
|
385
|
+
it("shows the trial line and Start free trial on the select step for an eligible fan", async () => {
|
|
386
|
+
mount({ tiers: [tier({ priceMonthly: 12, trialDays: 14, trialEligible: true })] });
|
|
387
|
+
|
|
388
|
+
const line = await screen.findByTestId("membership-checkout-trial-line");
|
|
389
|
+
const ends = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toLocaleDateString("en-US", {
|
|
390
|
+
year: "numeric",
|
|
391
|
+
month: "short",
|
|
392
|
+
day: "numeric",
|
|
393
|
+
});
|
|
394
|
+
expect(line.textContent).toBe(`14-day free trial. Your first charge is on ${ends}.`);
|
|
395
|
+
expect(screen.getByRole("button", { name: "Start free trial" })).toBeTruthy();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("shows neither to a fan who already had a trial", async () => {
|
|
399
|
+
mount({ tiers: [tier({ priceMonthly: 12, trialDays: 14, trialEligible: false })] });
|
|
400
|
+
|
|
401
|
+
await screen.findByText("Inner Circle", { exact: false });
|
|
402
|
+
expect(screen.queryByTestId("membership-checkout-trial-line")).toBeNull();
|
|
403
|
+
expect(screen.queryByRole("button", { name: "Start free trial" })).toBeNull();
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe("tier questionnaires", () => {
|
|
408
|
+
const questions = [
|
|
409
|
+
{ id: "q-why", question: "Why do you want to join?", type: "textarea" },
|
|
410
|
+
{ id: "q-city", question: "Which city?", type: "select", options: ["Berlin", "Lagos"] },
|
|
411
|
+
{ id: "q-handle", question: "Instagram handle", type: "text", optional: true },
|
|
412
|
+
];
|
|
413
|
+
const applicationPosts = () =>
|
|
414
|
+
sent.filter((r) => r.method === "post" && r.url.includes("/public/membership-applications"));
|
|
415
|
+
|
|
416
|
+
it("renders the questions, required ones marked, and no free-text message box", async () => {
|
|
417
|
+
mount({ tiers: [tier({ priceMonthly: 0, joinMode: "application", questionnaire: questions })] });
|
|
418
|
+
|
|
419
|
+
const why = (await screen.findByLabelText(/Why do you want to join\?/)) as HTMLTextAreaElement;
|
|
420
|
+
expect(why.tagName).toBe("TEXTAREA");
|
|
421
|
+
expect(why.required).toBe(true);
|
|
422
|
+
expect((screen.getByLabelText(/Which city\?/) as HTMLSelectElement).tagName).toBe("SELECT");
|
|
423
|
+
expect((screen.getByLabelText(/Instagram handle/) as HTMLInputElement).required).toBe(false);
|
|
424
|
+
expect(screen.queryByText(/Anything you'd like us to know/i)).toBeNull();
|
|
425
|
+
expect(screen.queryByPlaceholderText(/Tell us a little about yourself/i)).toBeNull();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("refuses a blank required answer beside the questions and sends nothing", async () => {
|
|
429
|
+
mount({ tiers: [tier({ priceMonthly: 10, questionnaire: questions })] });
|
|
430
|
+
|
|
431
|
+
await screen.findByLabelText(/Why do you want to join\?/);
|
|
432
|
+
fireEvent.click(subscribeButton());
|
|
433
|
+
|
|
434
|
+
const refusal = await screen.findByTestId("membership-questionnaire-refusal");
|
|
435
|
+
expect(refusal.textContent).toBe("Please answer: Why do you want to join?");
|
|
436
|
+
expect(screen.getAllByText("This field is required")).toHaveLength(2);
|
|
437
|
+
expect(subscriptionPosts()).toHaveLength(0);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("sends the answers with the paid subscription", async () => {
|
|
441
|
+
mount({ tiers: [tier({ priceMonthly: 10, questionnaire: questions })] });
|
|
442
|
+
|
|
443
|
+
fireEvent.change(await screen.findByLabelText(/Why do you want to join\?/), { target: { value: "Big fan" } });
|
|
444
|
+
fireEvent.change(screen.getByLabelText(/Which city\?/), { target: { value: "Berlin" } });
|
|
445
|
+
fireEvent.click(subscribeButton());
|
|
446
|
+
|
|
447
|
+
await waitFor(() => expect(subscriptionPosts()).toHaveLength(1));
|
|
448
|
+
expect(subscriptionPosts()[0].body?.questionnaire).toEqual([
|
|
449
|
+
{ id: "q-why", question: "Why do you want to join?", type: "textarea", answer: "Big fan" },
|
|
450
|
+
{ id: "q-city", question: "Which city?", type: "select", options: ["Berlin", "Lagos"], answer: "Berlin" },
|
|
451
|
+
{ id: "q-handle", question: "Instagram handle", type: "text", answer: "" },
|
|
452
|
+
]);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("after applying, links to the account page rather than the membership list", async () => {
|
|
456
|
+
mount({ tiers: [tier({ priceMonthly: 0, joinMode: "application", questionnaire: questions })] });
|
|
457
|
+
|
|
458
|
+
fireEvent.change(await screen.findByLabelText(/Why do you want to join\?/), { target: { value: "Big fan" } });
|
|
459
|
+
fireEvent.change(screen.getByLabelText(/Which city\?/), { target: { value: "Lagos" } });
|
|
460
|
+
fireEvent.click(screen.getByRole("button", { name: "Apply to join" }));
|
|
461
|
+
|
|
462
|
+
await waitFor(() => expect(applicationPosts()).toHaveLength(1));
|
|
463
|
+
expect(applicationPosts()[0].body).not.toHaveProperty("message");
|
|
464
|
+
expect(applicationPosts()[0].body?.questionnaire).toHaveLength(3);
|
|
465
|
+
expect(await screen.findByText("Application received")).toBeTruthy();
|
|
466
|
+
const link = screen.getByRole("link", { name: "Go to your account" }) as HTMLAnchorElement;
|
|
467
|
+
expect(link.getAttribute("href")).toBe("/i/account");
|
|
468
|
+
expect(screen.queryByRole("link", { name: "View memberships" })).toBeNull();
|
|
469
|
+
});
|
|
470
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, 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 type { MembershipApplicationStatus, MembershipTier } from "../../../types/models";
|
|
10
|
+
import { MembershipTiers } from "../MembershipTiers";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The tier card under curated access, over the REAL query layer: what the card
|
|
14
|
+
* offers for each join mode and each state of the fan's own application, that
|
|
15
|
+
* the invite token on the url reaches the tier list request, and that Withdraw
|
|
16
|
+
* posts to the right application.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const PROFILE_ID = "profile-1";
|
|
20
|
+
let sent: { method: string; url: string; params: Record<string, unknown>; body: Record<string, unknown> | null }[] = [];
|
|
21
|
+
let realAdapter: AxiosAdapter | undefined;
|
|
22
|
+
|
|
23
|
+
const tier = (id: string, overrides: Partial<MembershipTier> = {}): MembershipTier => ({
|
|
24
|
+
id,
|
|
25
|
+
name: `Tier ${id}`,
|
|
26
|
+
description: "",
|
|
27
|
+
priceMonthly: 10,
|
|
28
|
+
payWhatYouWant: false,
|
|
29
|
+
benefits: [],
|
|
30
|
+
...overrides,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const withApplication = (id: string, status: MembershipApplicationStatus) =>
|
|
34
|
+
tier(id, { joinMode: "application", myApplication: { id: `app-${id}`, status, paymentWindowExpiresAt: null } });
|
|
35
|
+
|
|
36
|
+
function mount(tiers: MembershipTier[]) {
|
|
37
|
+
sent = [];
|
|
38
|
+
const adapter: AxiosAdapter = async (config: InternalAxiosRequestConfig) => {
|
|
39
|
+
const url = config.url ?? "";
|
|
40
|
+
const body = config.data ? (JSON.parse(config.data as string) as Record<string, unknown>) : null;
|
|
41
|
+
sent.push({ method: (config.method ?? "get").toLowerCase(), url, params: config.params ?? {}, body });
|
|
42
|
+
const reply = (data: unknown) =>
|
|
43
|
+
Promise.resolve({ data, status: 200, statusText: "OK", headers: {}, config } as never);
|
|
44
|
+
if (url.includes("/public/membership-tiers")) return reply(tiers);
|
|
45
|
+
if (url.includes("/withdraw")) return reply({ application: { id: "x", status: "withdrawn" } });
|
|
46
|
+
return reply({});
|
|
47
|
+
};
|
|
48
|
+
realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
|
|
49
|
+
axios.defaults.adapter = adapter;
|
|
50
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
51
|
+
return render(
|
|
52
|
+
<QueryClientProvider client={queryClient}>
|
|
53
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
|
|
54
|
+
<PublicAuthContext.Provider value={{ user: null, isInitialized: true, isAuthenticated: false } as never}>
|
|
55
|
+
<ForgeThemeProvider theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}>
|
|
56
|
+
<MembershipTiers />
|
|
57
|
+
</ForgeThemeProvider>
|
|
58
|
+
</PublicAuthContext.Provider>
|
|
59
|
+
</ForgeClientProvider>
|
|
60
|
+
</QueryClientProvider>,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
cleanup();
|
|
66
|
+
if (realAdapter) axios.defaults.adapter = realAdapter;
|
|
67
|
+
window.history.replaceState(null, "", "/");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const cardOf = async (name: string) => {
|
|
71
|
+
const heading = await screen.findByRole("heading", { name });
|
|
72
|
+
return heading.parentElement as HTMLElement;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
describe("MembershipTiers: the card per join mode and application state", () => {
|
|
76
|
+
it("reads Select, Accept invite and Apply to join by join mode", async () => {
|
|
77
|
+
mount([tier("open"), tier("inv", { joinMode: "invite" }), tier("app", { joinMode: "application" })]);
|
|
78
|
+
|
|
79
|
+
expect((await cardOf("Tier open")).textContent).toContain("Select");
|
|
80
|
+
expect((await cardOf("Tier inv")).textContent).toContain("Accept invite");
|
|
81
|
+
expect((await cardOf("Tier app")).textContent).toContain("Apply to join");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("shows the application's state instead of the join button", async () => {
|
|
85
|
+
mount([
|
|
86
|
+
withApplication("p", "pending"),
|
|
87
|
+
withApplication("a", "approved"),
|
|
88
|
+
withApplication("f", "payment_failed"),
|
|
89
|
+
withApplication("d", "declined"),
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
const pending = await cardOf("Tier p");
|
|
93
|
+
expect(pending.textContent).toContain("Application pending");
|
|
94
|
+
expect(pending.textContent).toContain("Withdraw application");
|
|
95
|
+
expect(pending.textContent).not.toContain("Apply to join");
|
|
96
|
+
|
|
97
|
+
const approved = await cardOf("Tier a");
|
|
98
|
+
expect(approved.textContent).toContain("Approved, finishing up");
|
|
99
|
+
expect(approved.querySelector("button")).toBeNull();
|
|
100
|
+
|
|
101
|
+
const failed = await cardOf("Tier f");
|
|
102
|
+
expect(failed.textContent).toContain("Approved: complete your membership");
|
|
103
|
+
expect(failed.textContent).toContain("Complete your membership");
|
|
104
|
+
|
|
105
|
+
const declined = await cardOf("Tier d");
|
|
106
|
+
expect(declined.textContent).toContain("Not approved");
|
|
107
|
+
expect(declined.querySelector("button")).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("withdraws the pending application it shows", async () => {
|
|
111
|
+
mount([withApplication("p", "pending")]);
|
|
112
|
+
const card = await cardOf("Tier p");
|
|
113
|
+
fireEvent.click(card.querySelector("button") as HTMLButtonElement);
|
|
114
|
+
|
|
115
|
+
await waitFor(() => expect(sent.some((r) => r.method === "post" && r.url.includes("/withdraw"))).toBe(true));
|
|
116
|
+
const post = sent.find((r) => r.url.includes("/withdraw"))!;
|
|
117
|
+
expect(post.url).toContain("/public/membership-applications/app-p/withdraw");
|
|
118
|
+
expect(post.body).toEqual({ profileId: PROFILE_ID });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("the invite token reaches the tier list", () => {
|
|
123
|
+
it("forwards `?invite=` as inviteToken", async () => {
|
|
124
|
+
window.history.replaceState(null, "", "/i/membership?invite=tok-123");
|
|
125
|
+
mount([tier("inv", { joinMode: "invite" })]);
|
|
126
|
+
await cardOf("Tier inv");
|
|
127
|
+
|
|
128
|
+
const get = sent.find((r) => r.url.includes("/public/membership-tiers"))!;
|
|
129
|
+
expect(get.params).toEqual({ profileId: PROFILE_ID, inviteToken: "tok-123" });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("sends no inviteToken without one", async () => {
|
|
133
|
+
mount([tier("open")]);
|
|
134
|
+
await cardOf("Tier open");
|
|
135
|
+
|
|
136
|
+
const get = sent.find((r) => r.url.includes("/public/membership-tiers"))!;
|
|
137
|
+
expect(get.params).toEqual({ profileId: PROFILE_ID });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
3
|
+
import { render, screen, 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 type { MembershipTier } from "../../../types/models";
|
|
10
|
+
import { MembershipTiers } from "../MembershipTiers";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The tier card with a free trial: the badge and the "Start free trial" button
|
|
14
|
+
* appear only when this fan would get the trial, an application tier keeps
|
|
15
|
+
* "Apply to join", and a member with a live subscription (changing tier) is
|
|
16
|
+
* offered no trial.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
let realAdapter: AxiosAdapter | undefined;
|
|
20
|
+
|
|
21
|
+
const tier = (id: string, overrides: Partial<MembershipTier> = {}): MembershipTier => ({
|
|
22
|
+
id,
|
|
23
|
+
name: `Tier ${id}`,
|
|
24
|
+
description: "",
|
|
25
|
+
priceMonthly: 10,
|
|
26
|
+
payWhatYouWant: false,
|
|
27
|
+
benefits: [],
|
|
28
|
+
...overrides,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function mount(tiers: MembershipTier[], user: unknown = null) {
|
|
32
|
+
const adapter: AxiosAdapter = async (config: InternalAxiosRequestConfig) =>
|
|
33
|
+
({ data: (config.url ?? "").includes("/public/membership-tiers") ? tiers : {}, status: 200, statusText: "OK", headers: {}, config }) as never;
|
|
34
|
+
realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
|
|
35
|
+
axios.defaults.adapter = adapter;
|
|
36
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
37
|
+
return render(
|
|
38
|
+
<QueryClientProvider client={queryClient}>
|
|
39
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId="profile-1">
|
|
40
|
+
<PublicAuthContext.Provider value={{ user, isInitialized: true, isAuthenticated: !!user } as never}>
|
|
41
|
+
<ForgeThemeProvider theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}>
|
|
42
|
+
<MembershipTiers />
|
|
43
|
+
</ForgeThemeProvider>
|
|
44
|
+
</PublicAuthContext.Provider>
|
|
45
|
+
</ForgeClientProvider>
|
|
46
|
+
</QueryClientProvider>,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
cleanup();
|
|
52
|
+
if (realAdapter) axios.defaults.adapter = realAdapter;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const cardOf = async (name: string) => (await screen.findByRole("heading", { name })).parentElement as HTMLElement;
|
|
56
|
+
const badge = (card: HTMLElement) => card.querySelector('[data-testid="membership-tier-trial-badge"]');
|
|
57
|
+
|
|
58
|
+
describe("MembershipTiers: free trial badge and button", () => {
|
|
59
|
+
it("shows the badge and Start free trial when eligible, including for a signed-out visitor", async () => {
|
|
60
|
+
mount([tier("eligible", { trialDays: 14, trialEligible: true }), tier("anon", { trialDays: 7 })]);
|
|
61
|
+
|
|
62
|
+
const eligible = await cardOf("Tier eligible");
|
|
63
|
+
expect(badge(eligible)?.textContent).toBe("14-day free trial");
|
|
64
|
+
expect(eligible.querySelector("button")?.textContent).toBe("Start free trial");
|
|
65
|
+
|
|
66
|
+
const anon = await cardOf("Tier anon");
|
|
67
|
+
expect(badge(anon)?.textContent).toBe("7-day free trial");
|
|
68
|
+
expect(anon.querySelector("button")?.textContent).toBe("Start free trial");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("shows no badge and the plain button to a fan who already had a trial, or on a tier without one", async () => {
|
|
72
|
+
mount([tier("used", { trialDays: 14, trialEligible: false }), tier("none", { trialDays: null })]);
|
|
73
|
+
|
|
74
|
+
for (const name of ["Tier used", "Tier none"]) {
|
|
75
|
+
const card = await cardOf(name);
|
|
76
|
+
expect(badge(card), name).toBeNull();
|
|
77
|
+
expect(card.querySelector("button")?.textContent, name).toBe("Select");
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("keeps Apply to join on an application tier, with the trial badge", async () => {
|
|
82
|
+
mount([tier("app", { joinMode: "application", trialDays: 30 })]);
|
|
83
|
+
const card = await cardOf("Tier app");
|
|
84
|
+
expect(badge(card)?.textContent).toBe("30-day free trial");
|
|
85
|
+
expect(card.querySelector("button")?.textContent).toBe("Apply to join");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("offers no trial to a member with a live paid subscription (a tier change)", async () => {
|
|
89
|
+
mount([tier("t", { trialDays: 14, trialEligible: true })], {
|
|
90
|
+
id: "account-1",
|
|
91
|
+
membership: { paymentProviderSubscriptionId: "sub_1", status: "active", membershipTierId: "other" },
|
|
92
|
+
});
|
|
93
|
+
const card = await cardOf("Tier t");
|
|
94
|
+
expect(badge(card)).toBeNull();
|
|
95
|
+
expect(card.querySelector("button")?.textContent).toBe("Select");
|
|
96
|
+
});
|
|
97
|
+
});
|