@tribe-nest/forge 3.36.0 → 3.37.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/i18n/de.json +4 -1
- package/src/i18n/en.json +4 -1
- package/src/ui/format/membershipPriceLabel.ts +29 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +136 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +18 -4
- package/src/ui/index.ts +1 -0
- package/src/ui/styled/AccountDashboard.tsx +16 -17
- package/src/ui/styled/LoginForm.tsx +15 -1
- package/src/ui/styled/MembershipTierCallout.tsx +91 -0
- package/src/ui/styled/MembershipTiers.tsx +5 -15
- package/src/ui/styled/SignupForm.tsx +10 -0
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +49 -34
- package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +126 -0
package/package.json
CHANGED
package/src/i18n/de.json
CHANGED
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
"forge.account_dashboard.cancelled": "Storniert",
|
|
23
23
|
"forge.account_dashboard.cancelling": "Wird storniert…",
|
|
24
24
|
"forge.account_dashboard.close": "Schließen",
|
|
25
|
-
"forge.account_dashboard.community_spaces": "Community-Bereiche",
|
|
26
25
|
"forge.account_dashboard.confirm_password": "Neues Passwort bestätigen",
|
|
27
26
|
"forge.account_dashboard.current_password": "Aktuelles Passwort",
|
|
28
27
|
"forge.account_dashboard.data_export_description": "Exportiere eine Kopie deiner persönlichen Daten.",
|
|
@@ -37,6 +36,7 @@
|
|
|
37
36
|
"forge.account_dashboard.last_name": "Nachname",
|
|
38
37
|
"forge.account_dashboard.leave_waitlist": "Warteliste verlassen",
|
|
39
38
|
"forge.account_dashboard.log_out": "Abmelden",
|
|
39
|
+
"forge.account_dashboard.member_area": "Mitgliederbereich",
|
|
40
40
|
"forge.account_dashboard.membership_cancel_error": "Wir konnten deine Mitgliedschaft nicht kündigen. Bitte versuche es erneut oder kontaktiere uns.",
|
|
41
41
|
"forge.account_dashboard.membership_change": "Ändern",
|
|
42
42
|
"forge.account_dashboard.membership_empty": "Du hast keine aktive Mitgliedschaft.",
|
|
@@ -939,6 +939,9 @@
|
|
|
939
939
|
"forge.membership_checkout.title": "{tier_name} beitreten",
|
|
940
940
|
"forge.membership_checkout.total": "Gesamt",
|
|
941
941
|
"forge.membership_checkout.view_memberships": "Mitgliedschaften ansehen",
|
|
942
|
+
"forge.membership_tier_callout.benefits_label": "Vorteile:",
|
|
943
|
+
"forge.membership_tier_callout.join_title": "{tier_name} beitreten",
|
|
944
|
+
"forge.membership_tier_callout.more_benefits": "+{count} weitere Vorteile",
|
|
942
945
|
"forge.membership_tiers.empty_title": "Keine Mitgliedschaften verfügbar.",
|
|
943
946
|
"forge.membership_tiers.heading": "Mitgliedschaft",
|
|
944
947
|
"forge.membership_tiers.price_free": "Kostenlos",
|
package/src/i18n/en.json
CHANGED
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
"forge.account_dashboard.cancelled": "Cancelled",
|
|
23
23
|
"forge.account_dashboard.cancelling": "Cancelling…",
|
|
24
24
|
"forge.account_dashboard.close": "Close",
|
|
25
|
-
"forge.account_dashboard.community_spaces": "Community spaces",
|
|
26
25
|
"forge.account_dashboard.confirm_password": "Confirm new password",
|
|
27
26
|
"forge.account_dashboard.current_password": "Current password",
|
|
28
27
|
"forge.account_dashboard.data_export_description": "Export a copy of your personal data.",
|
|
@@ -37,6 +36,7 @@
|
|
|
37
36
|
"forge.account_dashboard.last_name": "Last name",
|
|
38
37
|
"forge.account_dashboard.leave_waitlist": "Leave waitlist",
|
|
39
38
|
"forge.account_dashboard.log_out": "Log out",
|
|
39
|
+
"forge.account_dashboard.member_area": "Member Area",
|
|
40
40
|
"forge.account_dashboard.membership_cancel_error": "We couldn't cancel your membership. Please try again or contact us.",
|
|
41
41
|
"forge.account_dashboard.membership_change": "Change",
|
|
42
42
|
"forge.account_dashboard.membership_empty": "You don't have an active membership.",
|
|
@@ -939,6 +939,9 @@
|
|
|
939
939
|
"forge.membership_checkout.title": "Join {tier_name}",
|
|
940
940
|
"forge.membership_checkout.total": "Total",
|
|
941
941
|
"forge.membership_checkout.view_memberships": "View memberships",
|
|
942
|
+
"forge.membership_tier_callout.benefits_label": "Benefits:",
|
|
943
|
+
"forge.membership_tier_callout.join_title": "Join {tier_name}",
|
|
944
|
+
"forge.membership_tier_callout.more_benefits": "+{count} more benefits",
|
|
942
945
|
"forge.membership_tiers.empty_title": "No membership tiers available.",
|
|
943
946
|
"forge.membership_tiers.heading": "Membership",
|
|
944
947
|
"forge.membership_tiers.price_free": "Free",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ForgeT } from "../../i18n";
|
|
2
|
+
import type { MembershipTier } from "../../types/models";
|
|
3
|
+
import { cycleFloor, offeredCycles } from "./membershipPwyw";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The one sentence that prices a membership tier.
|
|
7
|
+
*
|
|
8
|
+
* Shared rather than copied, because the rule it encodes was a bug: a tier
|
|
9
|
+
* priced on ONE cycle has to be advertised on that cycle. Reading the monthly
|
|
10
|
+
* minimum unconditionally printed "Pay what you want from $0/mo" on a
|
|
11
|
+
* yearly-only tier, which reads as free and is the opposite of the truth. A
|
|
12
|
+
* second copy of this in the next surface that prices a tier would be the copy
|
|
13
|
+
* that misses the next fix.
|
|
14
|
+
*
|
|
15
|
+
* The keys stay under `forge.membership_tiers.*`: this is one sentence about one
|
|
16
|
+
* thing, and every surface that shows a tier price must say it the same way, so
|
|
17
|
+
* an operator editing the copy edits it once.
|
|
18
|
+
*/
|
|
19
|
+
export function membershipPriceLabel(tier: MembershipTier, t: ForgeT, fmt: (amount: number) => string): string {
|
|
20
|
+
const cycles = offeredCycles(tier);
|
|
21
|
+
if (tier.payWhatYouWant) {
|
|
22
|
+
return cycles.month
|
|
23
|
+
? t("forge.membership_tiers.price_pwyw", { amount: fmt(cycleFloor(tier, "month")) })
|
|
24
|
+
: t("forge.membership_tiers.price_pwyw_yearly", { amount: fmt(cycleFloor(tier, "year")) });
|
|
25
|
+
}
|
|
26
|
+
if (cycles.month) return t("forge.membership_tiers.price_monthly", { amount: fmt(cycleFloor(tier, "month")) });
|
|
27
|
+
if (cycles.year) return t("forge.membership_tiers.price_yearly", { amount: fmt(cycleFloor(tier, "year")) });
|
|
28
|
+
return t("forge.membership_tiers.price_free");
|
|
29
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
import { renderHook, act } from "@testing-library/react";
|
|
4
|
+
import { useMembershipCheckout } from "../useMembershipCheckout";
|
|
5
|
+
import type { MembershipTier } from "../../../../types/models";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The member the account page reads is re-read before the checkout hands over.
|
|
9
|
+
*
|
|
10
|
+
* A host that passes `onComplete` navigates with its own router, and that does
|
|
11
|
+
* NOT re-read the signed-in member: `user` is context state, fetched once when
|
|
12
|
+
* the page was first opened. So a fan who joined a free tier arrived on an
|
|
13
|
+
* account page still holding the user they were BEFORE they joined, and it
|
|
14
|
+
* offered them "Join" over a live membership until they refreshed by hand.
|
|
15
|
+
*
|
|
16
|
+
* Three seams, all mocked at the module boundary so nothing touches the network:
|
|
17
|
+
* the auth context (for `refetchUser`), the tier list, and the subscription
|
|
18
|
+
* mutations.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const auth = vi.hoisted(() => ({
|
|
22
|
+
user: { id: "account-1", membership: undefined as unknown },
|
|
23
|
+
refetchUser: vi.fn(async () => {}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const subscriptions = vi.hoisted(() => ({
|
|
27
|
+
createFree: vi.fn(async () => ({})),
|
|
28
|
+
createPaid: vi.fn(async () => ({}) as Record<string, unknown>),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
/** Every call in the order it happened, which is the whole assertion. */
|
|
32
|
+
const calls = vi.hoisted(() => [] as string[]);
|
|
33
|
+
|
|
34
|
+
vi.mock("../../../../contexts/PublicAuthContext", async (importOriginal) => ({
|
|
35
|
+
...(await importOriginal<typeof import("../../../../contexts/PublicAuthContext")>()),
|
|
36
|
+
usePublicAuth: () => ({ user: auth.user, refetchUser: auth.refetchUser }),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
vi.mock("../../../../data/queries/useMembership", async (importOriginal) => ({
|
|
40
|
+
...(await importOriginal<typeof import("../../../../data/queries/useMembership")>()),
|
|
41
|
+
useGetMembershipTiers: () => ({ data: tiers, isLoading: false }),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
vi.mock("../../../../data/queries/useSubscriptions", async (importOriginal) => ({
|
|
45
|
+
...(await importOriginal<typeof import("../../../../data/queries/useSubscriptions")>()),
|
|
46
|
+
useCreateSubscription: () => ({ mutateAsync: subscriptions.createPaid, isPending: false }),
|
|
47
|
+
useCreateFreeSubscription: () => ({ mutateAsync: subscriptions.createFree, isPending: false }),
|
|
48
|
+
useConfirmLatestSubscription: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const tier = (overrides: Partial<MembershipTier>): MembershipTier =>
|
|
52
|
+
({
|
|
53
|
+
id: "tier-free",
|
|
54
|
+
profileId: "profile-1",
|
|
55
|
+
name: "Free tier",
|
|
56
|
+
priceMonthly: 0,
|
|
57
|
+
priceYearly: null,
|
|
58
|
+
payWhatYouWant: false,
|
|
59
|
+
benefits: [],
|
|
60
|
+
...overrides,
|
|
61
|
+
}) as unknown as MembershipTier;
|
|
62
|
+
|
|
63
|
+
const tiers: MembershipTier[] = [
|
|
64
|
+
tier({}),
|
|
65
|
+
tier({ id: "tier-paid", name: "Inner Circle", priceMonthly: 10 } as Partial<MembershipTier>),
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
calls.length = 0;
|
|
70
|
+
auth.refetchUser.mockClear();
|
|
71
|
+
auth.refetchUser.mockImplementation(async () => {
|
|
72
|
+
calls.push("refetch");
|
|
73
|
+
});
|
|
74
|
+
subscriptions.createFree.mockClear();
|
|
75
|
+
subscriptions.createFree.mockResolvedValue({});
|
|
76
|
+
subscriptions.createPaid.mockClear();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const onComplete = () => calls.push("complete");
|
|
80
|
+
|
|
81
|
+
describe("useMembershipCheckout: the member is re-read before the host navigates", () => {
|
|
82
|
+
it("REGRESSION: refetches the member after a free activation, before handing over", async () => {
|
|
83
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-free", onComplete }));
|
|
84
|
+
|
|
85
|
+
await act(async () => {
|
|
86
|
+
await result.current.subscribe();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(subscriptions.createFree).toHaveBeenCalledTimes(1);
|
|
90
|
+
// Order matters: handing over first means the account page renders from the
|
|
91
|
+
// stale user and never re-renders, which IS the bug.
|
|
92
|
+
expect(calls).toEqual(["refetch", "complete"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("REGRESSION: refetches when the server settled a tier change with nothing to pay", async () => {
|
|
96
|
+
// Proration on an existing subscription: `requiresPayment` false, no card
|
|
97
|
+
// step, and the same stale-user landing as a free join.
|
|
98
|
+
subscriptions.createPaid.mockResolvedValue({ requiresPayment: false });
|
|
99
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-paid", onComplete }));
|
|
100
|
+
|
|
101
|
+
await act(async () => {
|
|
102
|
+
await result.current.subscribe();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(calls).toEqual(["refetch", "complete"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("leaves the confirmation leg to the account page", async () => {
|
|
109
|
+
// A real provider payment has to be reconciled first, and the account page
|
|
110
|
+
// re-reads the member AFTER that. Refetching here would read the membership
|
|
111
|
+
// a beat too early and cache the pre-payment answer.
|
|
112
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-paid", onComplete }));
|
|
113
|
+
|
|
114
|
+
await act(async () => {
|
|
115
|
+
await result.current.finish(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(auth.refetchUser).not.toHaveBeenCalled();
|
|
119
|
+
expect(calls).toEqual(["complete"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("does not refetch when the fan still has a card step in front of them", async () => {
|
|
123
|
+
// Stripe handed back a client secret: nothing has been paid, nothing has
|
|
124
|
+
// changed about the member, and the checkout is still on screen.
|
|
125
|
+
subscriptions.createPaid.mockResolvedValue({ requiresPayment: true, clientSecret: "cs_test" });
|
|
126
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-paid", onComplete }));
|
|
127
|
+
|
|
128
|
+
await act(async () => {
|
|
129
|
+
await result.current.subscribe();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(auth.refetchUser).not.toHaveBeenCalled();
|
|
133
|
+
expect(calls).toEqual([]);
|
|
134
|
+
expect(result.current.step).toBe("payment");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -79,7 +79,7 @@ const errMessage = (e: unknown) =>
|
|
|
79
79
|
* provider tree. Read that file before changing anything about money here.
|
|
80
80
|
*/
|
|
81
81
|
export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
82
|
-
const { user } = usePublicAuth();
|
|
82
|
+
const { user, refetchUser } = usePublicAuth();
|
|
83
83
|
const { data: tiers, isLoading } = useGetMembershipTiers();
|
|
84
84
|
const createPaid = useCreateSubscription();
|
|
85
85
|
const createFree = useCreateFreeSubscription();
|
|
@@ -167,7 +167,21 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
167
167
|
const isChange =
|
|
168
168
|
!!membership?.paymentProviderSubscriptionId && membership.status === "active" && !isFreeCycle && !!selectedTier;
|
|
169
169
|
|
|
170
|
-
|
|
170
|
+
/**
|
|
171
|
+
* `await`ed by every caller, because of the refetch below.
|
|
172
|
+
*
|
|
173
|
+
* A host that passes `onComplete` navigates with its own router, which does
|
|
174
|
+
* NOT re-read the signed-in member: `user` is context state, loaded once when
|
|
175
|
+
* the page was first opened. So a fan who joined a free tier, or moved tier by
|
|
176
|
+
* proration, landed on an account page still holding the user they were before
|
|
177
|
+
* they pressed the button, and it offered them "Join" over a membership that
|
|
178
|
+
* was already live until they hard-refreshed.
|
|
179
|
+
*
|
|
180
|
+
* The confirmation leg is left alone on purpose: it has a provider payment to
|
|
181
|
+
* reconcile first, and the account page re-reads the member AFTER that.
|
|
182
|
+
*/
|
|
183
|
+
const finish = async (requiresConfirmation: boolean) => {
|
|
184
|
+
if (!requiresConfirmation) await refetchUser();
|
|
171
185
|
if (opts.onComplete) {
|
|
172
186
|
opts.onComplete({ requiresConfirmation });
|
|
173
187
|
return;
|
|
@@ -207,7 +221,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
207
221
|
try {
|
|
208
222
|
if (isFreeCycle) {
|
|
209
223
|
await createFree.mutateAsync({ membershipTierId: selectedTier.id, attributionRefId, ...landing });
|
|
210
|
-
finish(false);
|
|
224
|
+
await finish(false);
|
|
211
225
|
return;
|
|
212
226
|
}
|
|
213
227
|
const data = await createPaid.mutateAsync({
|
|
@@ -228,7 +242,7 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
|
|
|
228
242
|
// membership nobody has paid for.
|
|
229
243
|
const requiresPayment = data.requiresPayment ?? !!(data.clientSecret || data.accessCode || data.checkoutUrl);
|
|
230
244
|
if (!requiresPayment) {
|
|
231
|
-
finish(false);
|
|
245
|
+
await finish(false);
|
|
232
246
|
return;
|
|
233
247
|
}
|
|
234
248
|
|
package/src/ui/index.ts
CHANGED
|
@@ -96,6 +96,7 @@ export { ProductBrowseNav, type ProductBrowseNavProps } from "./styled/ProductBr
|
|
|
96
96
|
export { Addons, type AddonsProps } from "./styled/Addons";
|
|
97
97
|
export { BundleConfirmation, type BundleConfirmationProps } from "./styled/BundleConfirmation";
|
|
98
98
|
export { ProductDetail, type ProductDetailProps } from "./styled/ProductDetail";
|
|
99
|
+
export { MembershipTierCallout, type MembershipTierCalloutProps } from "./styled/MembershipTierCallout";
|
|
99
100
|
export { MembershipTiers, type MembershipTiersProps } from "./styled/MembershipTiers";
|
|
100
101
|
export { EventsList, type EventsListProps } from "./styled/EventsList";
|
|
101
102
|
export { EventTickets, type EventTicketsProps } from "./styled/EventTickets";
|
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
useCancelMembership,
|
|
23
23
|
useMembershipAccess,
|
|
24
24
|
useOpenBillingPortal,
|
|
25
|
-
useCommunitySpaces,
|
|
26
25
|
useUpdateAccount,
|
|
27
26
|
useChangePassword,
|
|
28
27
|
useExportAccountData,
|
|
@@ -98,14 +97,14 @@ export interface AccountDashboardProps {
|
|
|
98
97
|
*/
|
|
99
98
|
onNavigateMembership?: () => void;
|
|
100
99
|
/**
|
|
101
|
-
* Navigate to the
|
|
102
|
-
*
|
|
100
|
+
* Navigate to the member area, the member home (host maps this to its router,
|
|
101
|
+
* e.g. `/i/members`).
|
|
103
102
|
*
|
|
104
|
-
* Optional, and drawn
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
103
|
+
* Optional, and drawn for every signed-in visitor, membership or none. It used
|
|
104
|
+
* to be gated on `/public/community/spaces` returning a space they could open,
|
|
105
|
+
* which hid the member area from exactly the people who had not joined yet:
|
|
106
|
+
* the member home itself shows the locked posts and the way to buy access, so
|
|
107
|
+
* the door is never a dead end.
|
|
109
108
|
*/
|
|
110
109
|
onNavigateCommunity?: () => void;
|
|
111
110
|
/**
|
|
@@ -211,8 +210,8 @@ export function AccountDashboard({
|
|
|
211
210
|
const { data: siteConfig } = useSiteConfig();
|
|
212
211
|
/**
|
|
213
212
|
* Does this fan hold any rental at all? That is the whole gate on the Rentals
|
|
214
|
-
* tab
|
|
215
|
-
*
|
|
213
|
+
* tab: the server's own answer decides, and a surface with nothing behind it
|
|
214
|
+
* is not drawn.
|
|
216
215
|
*
|
|
217
216
|
* Film rentals are off by default and per profile, so on most sites this read
|
|
218
217
|
* comes back empty for ever and the tab never appears. It is the SAME query
|
|
@@ -305,12 +304,12 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
305
304
|
const tr = useForgeT();
|
|
306
305
|
const { formatAmount, formatDate, currency, onNavigateMembership, onNavigateCommunity } = ctx;
|
|
307
306
|
const cancelMembership = useCancelMembership();
|
|
308
|
-
// The way into the
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
const canOpenCommunity = !!onNavigateCommunity &&
|
|
307
|
+
// The way into the member area. Being signed in is the whole gate: a fan with
|
|
308
|
+
// no membership needs this door most, because the member home is where the
|
|
309
|
+
// locked posts and the offer to join actually live. The old gate asked
|
|
310
|
+
// `/public/community/spaces` for an openable space first, so a fan who had not
|
|
311
|
+
// joined saw no way in at all.
|
|
312
|
+
const canOpenCommunity = !!onNavigateCommunity && !!user;
|
|
314
313
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
315
314
|
/**
|
|
316
315
|
* Cancelling is destructive and irreversible from here, so it asks first.
|
|
@@ -426,7 +425,7 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
426
425
|
what the membership is FOR, not another thing to do to the plan. */}
|
|
427
426
|
{canOpenCommunity && (
|
|
428
427
|
<button onClick={() => onNavigateCommunity?.()} style={{ ...ghostButton, marginRight: "auto" }}>
|
|
429
|
-
{tr("forge.account_dashboard.
|
|
428
|
+
{tr("forge.account_dashboard.member_area")}
|
|
430
429
|
</button>
|
|
431
430
|
)}
|
|
432
431
|
{message.showUpdatePayment && (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useLoginFlow } from "../headless/auth/useLoginFlow";
|
|
2
2
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
3
3
|
import { useForgeT } from "../../i18n";
|
|
4
|
+
import { MembershipTierCallout } from "./MembershipTierCallout";
|
|
4
5
|
|
|
5
6
|
export interface LoginFormProps {
|
|
6
7
|
/** Called after a successful login. The shell typically redirects here. */
|
|
@@ -9,10 +10,17 @@ export interface LoginFormProps {
|
|
|
9
10
|
signupHref: string;
|
|
10
11
|
/** Href for the "Forgot password?" link. */
|
|
11
12
|
forgotHref: string;
|
|
13
|
+
/**
|
|
14
|
+
* The tier this visitor was on their way to join, shown above the form so
|
|
15
|
+
* they can see the sign-in wall is part of joining and which tier they picked.
|
|
16
|
+
* Hosts read it off the `redirect` they were sent here with. Left out, the
|
|
17
|
+
* form looks exactly as it did.
|
|
18
|
+
*/
|
|
19
|
+
membershipTierId?: string;
|
|
12
20
|
}
|
|
13
21
|
|
|
14
22
|
/** Themed 2FA login form (credentials → emailed code), built on `useLoginFlow`. */
|
|
15
|
-
export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps) {
|
|
23
|
+
export function LoginForm({ onSuccess, signupHref, forgotHref, membershipTierId }: LoginFormProps) {
|
|
16
24
|
const t = useForgeT();
|
|
17
25
|
const theme = useForgeTheme();
|
|
18
26
|
const flow = useLoginFlow({ onSuccess });
|
|
@@ -60,6 +68,9 @@ export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps)
|
|
|
60
68
|
if (flow.step === "code") {
|
|
61
69
|
return (
|
|
62
70
|
<div style={card}>
|
|
71
|
+
{/* Still on the way to the tier they picked, so it stays in view while
|
|
72
|
+
they fetch the code out of their inbox. */}
|
|
73
|
+
<MembershipTierCallout membershipTierId={membershipTierId} />
|
|
63
74
|
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>{t("forge.login_form.verify_title")}</h1>
|
|
64
75
|
<p style={{ fontSize: 14, opacity: 0.7, marginBottom: 24 }}>
|
|
65
76
|
{t("forge.login_form.code_sent_to")} <strong>{flow.email}</strong>
|
|
@@ -105,6 +116,9 @@ export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps)
|
|
|
105
116
|
|
|
106
117
|
return (
|
|
107
118
|
<div style={card}>
|
|
119
|
+
{/* What the fan was doing when they hit this wall. Nothing is drawn when
|
|
120
|
+
they came here on their own. */}
|
|
121
|
+
<MembershipTierCallout membershipTierId={membershipTierId} />
|
|
108
122
|
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>{t("forge.login_form.title")}</h1>
|
|
109
123
|
{flow.error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{flow.error}</p>}
|
|
110
124
|
<form
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { useGetMembershipTiers } from "../../data/queries/useMembership";
|
|
3
|
+
import { useForgeT } from "../../i18n";
|
|
4
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
5
|
+
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
6
|
+
import { membershipPriceLabel } from "../format/membershipPriceLabel";
|
|
7
|
+
|
|
8
|
+
export interface MembershipTierCalloutProps {
|
|
9
|
+
/** The tier the visitor is on their way to join. Nothing is drawn without it. */
|
|
10
|
+
membershipTierId?: string;
|
|
11
|
+
/** How many benefits to list before summarising the rest. Default 3. */
|
|
12
|
+
maxBenefits?: number;
|
|
13
|
+
className?: string;
|
|
14
|
+
style?: CSSProperties;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* "Join {tier}", with its price and a few of its benefits.
|
|
19
|
+
*
|
|
20
|
+
* Drawn above the login and signup forms when a visitor was sent there from a
|
|
21
|
+
* membership checkout. Without it the fan clicks Join, gets an unexplained
|
|
22
|
+
* sign-in wall, and has to remember which tier they picked and trust that the
|
|
23
|
+
* site remembers too. The client app has always shown this; code websites did
|
|
24
|
+
* not, so this is the same reassurance in a Forge component.
|
|
25
|
+
*
|
|
26
|
+
* Renders NOTHING when there is no tier id, when the tiers have not loaded, or
|
|
27
|
+
* when the id matches nothing the artist sells. A form that pushes itself down
|
|
28
|
+
* the page a beat after it appeared is worse than one that never moved, and an
|
|
29
|
+
* id from a stale link must not print an empty box.
|
|
30
|
+
*/
|
|
31
|
+
export function MembershipTierCallout({
|
|
32
|
+
membershipTierId,
|
|
33
|
+
maxBenefits = 3,
|
|
34
|
+
className,
|
|
35
|
+
style,
|
|
36
|
+
}: MembershipTierCalloutProps) {
|
|
37
|
+
const t = useForgeT();
|
|
38
|
+
const tokens = useThemeTokens();
|
|
39
|
+
const fmt = useAmountFormatter();
|
|
40
|
+
// The tier list is one cached query per profile, so this shares the fetch the
|
|
41
|
+
// membership page and the checkout already make rather than adding a request.
|
|
42
|
+
const { data: tiers } = useGetMembershipTiers();
|
|
43
|
+
const tier = membershipTierId ? tiers?.find((candidate) => candidate.id === membershipTierId) : undefined;
|
|
44
|
+
|
|
45
|
+
if (!tier) return null;
|
|
46
|
+
|
|
47
|
+
const benefits = tier.benefits ?? [];
|
|
48
|
+
const shown = benefits.slice(0, maxBenefits);
|
|
49
|
+
const remaining = benefits.length - shown.length;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className={className}
|
|
54
|
+
style={{
|
|
55
|
+
// No width and no outer margin: this is placed INSIDE the host's card
|
|
56
|
+
// (the login and signup forms put it above their heading), so it must
|
|
57
|
+
// not carry a column of its own that fights the one around it.
|
|
58
|
+
marginBottom: 24,
|
|
59
|
+
padding: 16,
|
|
60
|
+
borderRadius: tokens.cornerRadius,
|
|
61
|
+
background: `${tokens.primary}1a`,
|
|
62
|
+
color: tokens.text,
|
|
63
|
+
fontFamily: tokens.fontFamily,
|
|
64
|
+
...style,
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 8, fontFamily: tokens.headingFontFamily }}>
|
|
68
|
+
{t("forge.membership_tier_callout.join_title", { tier_name: tier.name })}
|
|
69
|
+
</h2>
|
|
70
|
+
<p style={{ fontSize: 14, marginBottom: shown.length ? 12 : 0 }}>{membershipPriceLabel(tier, t, fmt)}</p>
|
|
71
|
+
{!!shown.length && (
|
|
72
|
+
<div style={{ fontSize: 14 }}>
|
|
73
|
+
<p style={{ fontWeight: 500, marginBottom: 4 }}>{t("forge.membership_tier_callout.benefits_label")}</p>
|
|
74
|
+
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 4 }}>
|
|
75
|
+
{shown.map((benefit) => (
|
|
76
|
+
<li key={benefit.id} style={{ display: "flex", gap: 8 }}>
|
|
77
|
+
<span style={{ color: tokens.primary }}>✓</span>
|
|
78
|
+
{benefit.title}
|
|
79
|
+
</li>
|
|
80
|
+
))}
|
|
81
|
+
{remaining > 0 && (
|
|
82
|
+
<li style={{ fontSize: 12, opacity: 0.75 }}>
|
|
83
|
+
{t("forge.membership_tier_callout.more_benefits", { count: remaining })}
|
|
84
|
+
</li>
|
|
85
|
+
)}
|
|
86
|
+
</ul>
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -5,7 +5,7 @@ import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
|
5
5
|
import { priceTaxCaption } from "../format/PriceDisplay";
|
|
6
6
|
import { usePricesIncludeTax } from "../../data/queries/useWebsite";
|
|
7
7
|
import { useForgeT } from "../../i18n";
|
|
8
|
-
import {
|
|
8
|
+
import { membershipPriceLabel } from "../format/membershipPriceLabel";
|
|
9
9
|
import { Button } from "./Button";
|
|
10
10
|
import { Loading } from "./Loading";
|
|
11
11
|
|
|
@@ -53,20 +53,10 @@ export function MembershipTiers({
|
|
|
53
53
|
if (isLoading) return <Loading fullPage />;
|
|
54
54
|
if (!tiers?.length) return <p style={{ color: tokens.text }}>{t("forge.membership_tiers.empty_title")}</p>;
|
|
55
55
|
|
|
56
|
-
// A tier priced on ONE cycle must be advertised on that cycle.
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
const priceLabel = (tier: MembershipTier) =>
|
|
60
|
-
const cycles = offeredCycles(tier);
|
|
61
|
-
if (tier.payWhatYouWant) {
|
|
62
|
-
return cycles.month
|
|
63
|
-
? t("forge.membership_tiers.price_pwyw", { amount: fmt(cycleFloor(tier, "month")) })
|
|
64
|
-
: t("forge.membership_tiers.price_pwyw_yearly", { amount: fmt(cycleFloor(tier, "year")) });
|
|
65
|
-
}
|
|
66
|
-
if (cycles.month) return t("forge.membership_tiers.price_monthly", { amount: fmt(cycleFloor(tier, "month")) });
|
|
67
|
-
if (cycles.year) return t("forge.membership_tiers.price_yearly", { amount: fmt(cycleFloor(tier, "year")) });
|
|
68
|
-
return freeLabel;
|
|
69
|
-
};
|
|
56
|
+
// A tier priced on ONE cycle must be advertised on that cycle. The rule lives
|
|
57
|
+
// in `membershipPriceLabel` so the login and signup callout prices a tier with
|
|
58
|
+
// the same sentence this page does.
|
|
59
|
+
const priceLabel = (tier: MembershipTier) => membershipPriceLabel(tier, t, fmt);
|
|
70
60
|
|
|
71
61
|
const select = (tier: MembershipTier) => {
|
|
72
62
|
if (onSelect) return onSelect(tier);
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { useSignupForm } from "../headless/auth/useSignupForm";
|
|
2
2
|
import { useForgeT } from "../../i18n";
|
|
3
3
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
|
+
import { MembershipTierCallout } from "./MembershipTierCallout";
|
|
4
5
|
|
|
5
6
|
export interface SignupFormProps {
|
|
6
7
|
/** Called after a successful signup. The shell typically redirects here. */
|
|
7
8
|
onSuccess?: (smartLinkPath?: string) => void;
|
|
8
9
|
/** Href for the "Login" link. */
|
|
9
10
|
loginHref: string;
|
|
11
|
+
/**
|
|
12
|
+
* The tier this visitor is on their way to join. Shown above the form, so a
|
|
13
|
+
* fan who pressed Join and landed on a signup wall can see which tier they
|
|
14
|
+
* picked and that the wall is part of joining it. It also rides along on the
|
|
15
|
+
* create-account request, where a coupon can turn it into a membership.
|
|
16
|
+
*/
|
|
10
17
|
membershipTierId?: string;
|
|
11
18
|
couponCode?: string;
|
|
12
19
|
}
|
|
@@ -64,6 +71,9 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
|
|
|
64
71
|
|
|
65
72
|
return (
|
|
66
73
|
<div style={card}>
|
|
74
|
+
{/* What the fan was doing when they hit this wall, kept in view through
|
|
75
|
+
the emailed-code step as well. Nothing is drawn without a tier. */}
|
|
76
|
+
<MembershipTierCallout membershipTierId={membershipTierId} />
|
|
67
77
|
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>
|
|
68
78
|
{form.awaitingCode ? t("forge.signup_form.verify_title") : t("forge.signup_form.title")}
|
|
69
79
|
</h1>
|
|
@@ -8,21 +8,20 @@ import { communityKeys, type CommunitySpace } from "../../../data/queries/useCom
|
|
|
8
8
|
import { AccountDashboard } from "../AccountDashboard";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* The account page's way into the
|
|
11
|
+
* The account page's way into the member area.
|
|
12
12
|
*
|
|
13
13
|
* Before this, a signed-in member landed on the membership card and the only
|
|
14
14
|
* doors on it led back to billing: change the plan, cancel it, fix the card.
|
|
15
|
-
* The
|
|
15
|
+
* The member area they were paying for was reachable only by typing the URL.
|
|
16
16
|
*
|
|
17
|
-
* The
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* asserted as hard as the happy one.
|
|
17
|
+
* The first version gated the link on `/public/community/spaces` returning a
|
|
18
|
+
* space the caller could open, which hid it from precisely the fans who had not
|
|
19
|
+
* joined yet. Being signed in is now the whole gate: the member home shows the
|
|
20
|
+
* locked posts and the way to buy access, so it is never a dead end.
|
|
22
21
|
*
|
|
23
22
|
* Rendered through `react-dom/server`: no DOM, no effects, so the spaces query
|
|
24
|
-
* is SEEDED into the react-query cache rather than mocked
|
|
25
|
-
*
|
|
23
|
+
* is SEEDED into the react-query cache rather than mocked, which keeps the real
|
|
24
|
+
* data path in place for the rest of the card.
|
|
26
25
|
*/
|
|
27
26
|
|
|
28
27
|
const PROFILE_ID = "profile-1";
|
|
@@ -65,21 +64,29 @@ const member = {
|
|
|
65
64
|
},
|
|
66
65
|
};
|
|
67
66
|
|
|
67
|
+
/** The same signed-in fan, with no membership of any kind. */
|
|
68
|
+
const nonMember = { ...member, membership: undefined };
|
|
69
|
+
|
|
68
70
|
/**
|
|
69
71
|
* The membership tab as a Forge code website draws it.
|
|
70
72
|
*
|
|
71
|
-
* `spaces === null` stands for the query never having answered
|
|
72
|
-
*
|
|
73
|
+
* `spaces === null` stands for the query never having answered, which is the
|
|
74
|
+
* state every first paint sits in.
|
|
73
75
|
*/
|
|
74
|
-
function render(opts: {
|
|
76
|
+
function render(opts: {
|
|
77
|
+
spaces: CommunitySpace[] | null;
|
|
78
|
+
user?: typeof member | typeof nonMember | null;
|
|
79
|
+
onNavigateCommunity?: () => void;
|
|
80
|
+
}): string {
|
|
75
81
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
76
82
|
if (opts.spaces) queryClient.setQueryData(communityKeys.spaces(PROFILE_ID), opts.spaces);
|
|
83
|
+
const user = opts.user === undefined ? member : opts.user;
|
|
77
84
|
|
|
78
85
|
return renderToStaticMarkup(
|
|
79
86
|
<QueryClientProvider client={queryClient}>
|
|
80
87
|
<ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
|
|
81
88
|
<PublicAuthContext.Provider
|
|
82
|
-
value={{ user
|
|
89
|
+
value={{ user, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}
|
|
83
90
|
>
|
|
84
91
|
<ForgeThemeProvider
|
|
85
92
|
theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
|
|
@@ -96,39 +103,47 @@ function render(opts: { spaces: CommunitySpace[] | null; onNavigateCommunity?: (
|
|
|
96
103
|
);
|
|
97
104
|
}
|
|
98
105
|
|
|
99
|
-
describe("AccountDashboard: the way into the
|
|
100
|
-
it("REGRESSION: offers the
|
|
101
|
-
// The gap this closes. Without it the membership card's only exits
|
|
102
|
-
// Change and Cancel, and the
|
|
106
|
+
describe("AccountDashboard: the way into the member area", () => {
|
|
107
|
+
it("REGRESSION: offers the member area to a member who has a space to open", () => {
|
|
108
|
+
// The original gap this closes. Without it the membership card's only exits
|
|
109
|
+
// are Change and Cancel, and the member area is unreachable from here.
|
|
103
110
|
const html = render({ spaces: [space()], onNavigateCommunity: () => {} });
|
|
104
111
|
|
|
105
|
-
expect(html).toContain("
|
|
112
|
+
expect(html).toContain("Member Area");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("REGRESSION: offers the member area to a signed-in fan with NO membership", () => {
|
|
116
|
+
// The second gap. The link used to require an openable space, so a fan who
|
|
117
|
+
// had not joined saw no way in, which is the fan the member home is most
|
|
118
|
+
// useful to: that page shows the locked posts and the offer to join.
|
|
119
|
+
const html = render({ spaces: [], user: nonMember, onNavigateCommunity: () => {} });
|
|
120
|
+
|
|
121
|
+
expect(html).toContain("Member Area");
|
|
122
|
+
// Still the non-member card and not some other state.
|
|
123
|
+
expect(html).toContain("You don't have an active membership.");
|
|
106
124
|
});
|
|
107
125
|
|
|
108
|
-
it("REGRESSION:
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
const html = render({ spaces:
|
|
126
|
+
it("REGRESSION: offers it before the spaces query has answered", () => {
|
|
127
|
+
// First paint has no cached answer. The link no longer waits on that read,
|
|
128
|
+
// so it does not appear a beat late.
|
|
129
|
+
const html = render({ spaces: null, onNavigateCommunity: () => {} });
|
|
112
130
|
|
|
113
|
-
expect(html).
|
|
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");
|
|
131
|
+
expect(html).toContain("Member Area");
|
|
117
132
|
});
|
|
118
133
|
|
|
119
|
-
it("draws nothing when the host site has no
|
|
134
|
+
it("draws nothing when the host site has no member-area route to send anyone to", () => {
|
|
120
135
|
// `onNavigateCommunity` is optional. A site that leaves it out gets no
|
|
121
|
-
// button
|
|
136
|
+
// button rather than a dead one.
|
|
122
137
|
const html = render({ spaces: [space()] });
|
|
123
138
|
|
|
124
|
-
expect(html).not.toContain("
|
|
139
|
+
expect(html).not.toContain("Member Area");
|
|
125
140
|
});
|
|
126
141
|
|
|
127
|
-
it("
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
const html = render({ spaces: null, onNavigateCommunity: () => {} });
|
|
142
|
+
it("draws nothing for a visitor who is not signed in", () => {
|
|
143
|
+
// The member area is behind a login. Offering it to a signed-out visitor
|
|
144
|
+
// sends them at a guard, not at the content.
|
|
145
|
+
const html = render({ spaces: [space()], user: null, onNavigateCommunity: () => {} });
|
|
131
146
|
|
|
132
|
-
expect(html).not.toContain("
|
|
147
|
+
expect(html).not.toContain("Member Area");
|
|
133
148
|
});
|
|
134
149
|
});
|
|
@@ -0,0 +1,126 @@
|
|
|
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 { LoginForm } from "../LoginForm";
|
|
8
|
+
import { SignupForm } from "../SignupForm";
|
|
9
|
+
import type { MembershipTier } from "../../../types/models";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What a fan sees after pressing Join while signed out.
|
|
13
|
+
*
|
|
14
|
+
* The auth guard sends them to `/i/login?redirect=/i/membership/checkout?membershipTierId=…`,
|
|
15
|
+
* which up to now was a bare sign-in card: no mention of the tier they had just
|
|
16
|
+
* chosen, no sign that the wall was part of joining at all. The client app has
|
|
17
|
+
* always shown the tier on this screen, so this is code websites catching up.
|
|
18
|
+
*
|
|
19
|
+
* Rendered through `react-dom/server`, with the tier list SEEDED into the
|
|
20
|
+
* react-query cache rather than mocked, so the real `useGetMembershipTiers` stays
|
|
21
|
+
* in the path and a rename of its query key fails here.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const PROFILE_ID = "profile-1";
|
|
25
|
+
|
|
26
|
+
const tier: MembershipTier = {
|
|
27
|
+
id: "tier-1",
|
|
28
|
+
profileId: PROFILE_ID,
|
|
29
|
+
name: "Inner Circle",
|
|
30
|
+
description: "The good stuff",
|
|
31
|
+
priceMonthly: 10,
|
|
32
|
+
priceYearly: null,
|
|
33
|
+
payWhatYouWant: false,
|
|
34
|
+
payWhatYouWantMinimum: null,
|
|
35
|
+
payWhatYouWantMaximum: null,
|
|
36
|
+
isDefault: false,
|
|
37
|
+
archived: false,
|
|
38
|
+
order: 0,
|
|
39
|
+
benefits: [
|
|
40
|
+
{ id: "b1", title: "Early demos" },
|
|
41
|
+
{ id: "b2", title: "Monthly livestream" },
|
|
42
|
+
{ id: "b3", title: "Discord access" },
|
|
43
|
+
{ id: "b4", title: "Name in the credits" },
|
|
44
|
+
],
|
|
45
|
+
} as unknown as MembershipTier;
|
|
46
|
+
|
|
47
|
+
function render(node: React.ReactNode, opts: { tiers?: MembershipTier[] } = {}): string {
|
|
48
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
49
|
+
if (opts.tiers) queryClient.setQueryData(["membership-tiers", PROFILE_ID], opts.tiers);
|
|
50
|
+
// Settlement currency, or every price formats as "N/A".
|
|
51
|
+
queryClient.setQueryData(["site-config", PROFILE_ID], { currency: "USD" });
|
|
52
|
+
|
|
53
|
+
return renderToStaticMarkup(
|
|
54
|
+
<QueryClientProvider client={queryClient}>
|
|
55
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
|
|
56
|
+
<PublicAuthContext.Provider
|
|
57
|
+
value={{ user: null, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}
|
|
58
|
+
>
|
|
59
|
+
<ForgeThemeProvider
|
|
60
|
+
theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
|
|
61
|
+
>
|
|
62
|
+
{node}
|
|
63
|
+
</ForgeThemeProvider>
|
|
64
|
+
</PublicAuthContext.Provider>
|
|
65
|
+
</ForgeClientProvider>
|
|
66
|
+
</QueryClientProvider>,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("the tier a signed-out fan was on their way to join", () => {
|
|
71
|
+
it("REGRESSION: names the tier, its price and its benefits above the login form", () => {
|
|
72
|
+
const html = render(<LoginForm signupHref="/i/signup" forgotHref="/i/forgot-password" membershipTierId="tier-1" />, {
|
|
73
|
+
tiers: [tier],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(html).toContain("Join Inner Circle");
|
|
77
|
+
expect(html).toContain("$10/mo");
|
|
78
|
+
expect(html).toContain("Early demos");
|
|
79
|
+
// The login form itself is untouched underneath.
|
|
80
|
+
expect(html).toContain("Password");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("lists three benefits and counts the rest", () => {
|
|
84
|
+
// The whole list would push the form itself below the fold on a phone.
|
|
85
|
+
const html = render(<LoginForm signupHref="/i/signup" forgotHref="/i/forgot-password" membershipTierId="tier-1" />, {
|
|
86
|
+
tiers: [tier],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(html).toContain("Discord access");
|
|
90
|
+
expect(html).not.toContain("Name in the credits");
|
|
91
|
+
expect(html).toContain("+1 more benefits");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("REGRESSION: shows the same tier on the signup form", () => {
|
|
95
|
+
// A fan with no account gets sent on from login to signup, and the tier has
|
|
96
|
+
// to survive that hop or the reassurance disappears halfway through.
|
|
97
|
+
const html = render(<SignupForm loginHref="/i/login" membershipTierId="tier-1" />, { tiers: [tier] });
|
|
98
|
+
|
|
99
|
+
expect(html).toContain("Join Inner Circle");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("says nothing when the visitor came to log in on their own", () => {
|
|
103
|
+
const html = render(<LoginForm signupHref="/i/signup" forgotHref="/i/forgot-password" />, { tiers: [tier] });
|
|
104
|
+
|
|
105
|
+
expect(html).not.toContain("Join Inner Circle");
|
|
106
|
+
expect(html).toContain("Password");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("says nothing when the id matches no tier the artist sells", () => {
|
|
110
|
+
// A stale link, or a tier that has since been archived. An empty box with a
|
|
111
|
+
// heading and no tier in it would be worse than no box.
|
|
112
|
+
const html = render(<LoginForm signupHref="/i/signup" forgotHref="/i/forgot-password" membershipTierId="gone" />, {
|
|
113
|
+
tiers: [tier],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(html).not.toContain("Join");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("says nothing before the tier list has arrived", () => {
|
|
120
|
+
// First paint has no cached answer. Drawing a heading now and the tier a
|
|
121
|
+
// beat later would move the form under the visitor's cursor.
|
|
122
|
+
const html = render(<LoginForm signupHref="/i/signup" forgotHref="/i/forgot-password" membershipTierId="tier-1" />);
|
|
123
|
+
|
|
124
|
+
expect(html).not.toContain("Join Inner Circle");
|
|
125
|
+
});
|
|
126
|
+
});
|