@tribe-nest/forge 3.35.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.
@@ -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 { cycleFloor, offeredCycles } from "../format/membershipPwyw";
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. Reading the
57
- // monthly minimum unconditionally printed "Pay what you want from $0/mo" on a
58
- // yearly-only tier, which reads as free and is the opposite of the truth.
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 members corner.
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 community they were paying for was reachable only by typing the URL.
15
+ * The member area they were paying for was reachable only by typing the URL.
16
16
  *
17
- * The gate is the server's own answer. `/public/community/spaces` returns the
18
- * open spaces PLUS the ones gated to a tier the caller holds, so the list being
19
- * empty is the server saying "there is nothing here you can open". Drawing the
20
- * link anyway would walk a member into a bounce, which is why the empty case is
21
- * asserted as hard as the happy one.
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. That keeps the real
25
- * `useCommunitySpaces` in the path, and a rename of its query key fails here.
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 (the member has
72
- * no accessible spaces cached), which is the state a non-member sits in.
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: { spaces: CommunitySpace[] | null; onNavigateCommunity?: () => void }): string {
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: member, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}
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 members corner", () => {
100
- it("REGRESSION: offers the community to a member who has a space to open", () => {
101
- // The gap this closes. Without it the membership card's only exits are
102
- // Change and Cancel, and the community is unreachable from the account page.
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("Community spaces");
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&#x27;t have an active membership.");
106
124
  });
107
125
 
108
- it("REGRESSION: says nothing when the member can open no space", () => {
109
- // A link here would promise a door that is locked: the server returned no
110
- // space this account may enter, so the honest account page is silent.
111
- const html = render({ spaces: [], onNavigateCommunity: () => {} });
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).not.toContain("Community spaces");
114
- // The billing actions are untouched, so this is the entry point missing and
115
- // not the whole card failing to draw.
116
- expect(html).toContain("Change");
131
+ expect(html).toContain("Member Area");
117
132
  });
118
133
 
119
- it("draws nothing when the host site has no community route to send anyone to", () => {
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, even with spaces in the cache, rather than a dead one.
136
+ // button rather than a dead one.
122
137
  const html = render({ spaces: [space()] });
123
138
 
124
- expect(html).not.toContain("Community spaces");
139
+ expect(html).not.toContain("Member Area");
125
140
  });
126
141
 
127
- it("stays quiet while the spaces query has not answered yet", () => {
128
- // First paint has no cached answer. Guessing "yes" here would flash a link
129
- // and then withdraw it from members who never had access.
130
- const html = render({ spaces: null, onNavigateCommunity: () => {} });
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("Community spaces");
147
+ expect(html).not.toContain("Member Area");
133
148
  });
134
149
  });
@@ -0,0 +1,200 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { ForgeClientProvider } from "../../../provider/ForgeProvider";
5
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
6
+ import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
7
+ import type { MyFilmRental } from "../../../data/queries/useFilms";
8
+ import { AccountDashboard, type AccountTabKey } from "../AccountDashboard";
9
+
10
+ /**
11
+ * Rentals on the account page.
12
+ *
13
+ * A fan who rented a film could find their tickets, their orders and their
14
+ * coaching sessions on this page and no trace of the rental - `/i/films/library`
15
+ * held it, and nothing on the account page pointed there. So the tab exists, and
16
+ * these are the two things it must get right:
17
+ *
18
+ * LIVE - the window is open, so the row offers the way in (Resume when
19
+ * there is progress to resume, Watch otherwise).
20
+ * NOT LIVE - expired or refunded, so the row offers Rent again and NOT a
21
+ * Watch button that leads to a player which would refuse them.
22
+ *
23
+ * `isLive` is the SERVER's answer (`msRemaining`, `expiresAt`, `revokedAt` are
24
+ * all decided there). Nothing here recomputes it, and these specs assert the
25
+ * rendering of that flag rather than any clock arithmetic in the browser.
26
+ *
27
+ * Rendered through `react-dom/server`, like the community spec beside it: no
28
+ * DOM and no effects, so the rentals query is SEEDED into the react-query cache
29
+ * rather than mocked. That keeps the real `useMyFilmRentals` in the path, and a
30
+ * rename of its query key fails here.
31
+ */
32
+
33
+ const PROFILE_ID = "profile-1";
34
+ const ACCOUNT_ID = "account-1";
35
+
36
+ /** The key `useMyFilmRentals(accountId)` reads, at its default page and limit. */
37
+ const RENTALS_KEY = ["my-film-rentals", ACCOUNT_ID, PROFILE_ID, 1, 20];
38
+
39
+ const rental = (overrides: Partial<MyFilmRental> = {}): MyFilmRental => ({
40
+ id: "rental-1",
41
+ filmId: "film-1",
42
+ status: "active",
43
+ amountCents: 500,
44
+ currency: "USD",
45
+ rentalDurationHours: 48,
46
+ rentalOptionId: "option-1",
47
+ paidAt: "2026-08-01T00:00:00.000Z",
48
+ startBy: "2026-09-01T00:00:00.000Z",
49
+ firstPlayedAt: "2026-08-02T00:00:00.000Z",
50
+ expiresAt: "2026-08-04T00:00:00.000Z",
51
+ refundedAt: null,
52
+ revokedAt: null,
53
+ isLive: true,
54
+ msRemaining: 3 * 3600_000,
55
+ windowMs: 48 * 3600_000,
56
+ film: {
57
+ id: "film-1",
58
+ title: "The Long Goodbye",
59
+ slug: "the-long-goodbye",
60
+ kind: "film",
61
+ status: "published",
62
+ archivedAt: null,
63
+ cover: null,
64
+ },
65
+ progress: [],
66
+ ...overrides,
67
+ });
68
+
69
+ const fan = {
70
+ id: ACCOUNT_ID,
71
+ email: "fan@example.com",
72
+ firstName: "Fan",
73
+ lastName: "Person",
74
+ kind: "fan",
75
+ status: "active",
76
+ createdAt: "2026-01-01T00:00:00.000Z",
77
+ updatedAt: "2026-01-01T00:00:00.000Z",
78
+ membership: null,
79
+ };
80
+
81
+ function render(opts: { rentals: MyFilmRental[] | null; tab?: AccountTabKey }): string {
82
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
83
+ if (opts.rentals) {
84
+ queryClient.setQueryData(RENTALS_KEY, {
85
+ data: opts.rentals,
86
+ total: opts.rentals.length,
87
+ page: 1,
88
+ limit: 20,
89
+ });
90
+ }
91
+
92
+ return renderToStaticMarkup(
93
+ <QueryClientProvider client={queryClient}>
94
+ <ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
95
+ <PublicAuthContext.Provider
96
+ value={
97
+ {
98
+ user: fan,
99
+ isInitialized: true,
100
+ currencies: null,
101
+ userSelectedCurrency: "USD",
102
+ logout: () => {},
103
+ } as never
104
+ }
105
+ >
106
+ <ForgeThemeProvider
107
+ theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
108
+ >
109
+ <AccountDashboard tab={opts.tab ?? "rentals"} onNavigateMembership={() => {}} />
110
+ </ForgeThemeProvider>
111
+ </PublicAuthContext.Provider>
112
+ </ForgeClientProvider>
113
+ </QueryClientProvider>,
114
+ );
115
+ }
116
+
117
+ describe("AccountDashboard: the fan's rentals", () => {
118
+ it("REGRESSION: a running rental is reachable from the account page", () => {
119
+ // The gap this closes. The rental existed, the player existed, and the
120
+ // account page mentioned neither.
121
+ const html = render({ rentals: [rental()] });
122
+
123
+ expect(html).toContain("Rentals");
124
+ expect(html).toContain("The Long Goodbye");
125
+ expect(html).toContain("Watch");
126
+ // The window is state the fan acts on, so it is on the row rather than
127
+ // behind a `?`.
128
+ expect(html).toContain("3h 0m left");
129
+ expect(html).not.toContain("Rent again");
130
+ });
131
+
132
+ it("offers to resume the episode the fan stopped in the middle of", () => {
133
+ const html = render({
134
+ rentals: [rental({ progress: [{ episodeId: "episode-1", positionSec: 900, completedAt: null }] })],
135
+ });
136
+
137
+ expect(html).toContain("Resume");
138
+ });
139
+
140
+ it("REGRESSION: an expired rental offers Rent again, never Watch", () => {
141
+ // Drawing Watch here walks the fan into a player that refuses them: the
142
+ // window is the server's to judge and it has closed.
143
+ const html = render({
144
+ rentals: [rental({ isLive: false, msRemaining: 0, expiresAt: "2026-08-04T00:00:00.000Z" })],
145
+ });
146
+
147
+ expect(html).toContain("Rent again");
148
+ expect(html).toContain("Your window has closed.");
149
+ expect(html).not.toContain(">Watch<");
150
+ expect(html).not.toContain(">Resume<");
151
+ });
152
+
153
+ it("says a refunded rental was refunded rather than that it ran out", () => {
154
+ // Two different things happened, and "your window has closed" over a refund
155
+ // reads as the artist keeping the money.
156
+ const html = render({
157
+ rentals: [rental({ isLive: false, msRemaining: null, refundedAt: "2026-08-03T00:00:00.000Z" })],
158
+ });
159
+
160
+ expect(html).toContain("Refunded.");
161
+ expect(html).toContain("Rent again");
162
+ });
163
+
164
+ it("keeps a rental that has not been started apart from one that is running", () => {
165
+ // The clock starts at first play, so this fan has lost nothing by waiting.
166
+ // Saying "0m left" here would be a lie that costs them the film.
167
+ const html = render({ rentals: [rental({ firstPlayedAt: null, msRemaining: null, expiresAt: null })] });
168
+
169
+ expect(html).toContain("Not started.");
170
+ expect(html).toContain("Watch");
171
+ });
172
+
173
+ it("does not draw the tab on a site where this fan holds no rental", () => {
174
+ // Film rentals are off by default and per profile. An always-empty tab on
175
+ // every account page is noise on every site that never sells one.
176
+ const html = render({ rentals: [], tab: "membership" });
177
+
178
+ expect(html).not.toContain("Rentals");
179
+ // The rest of the strip is untouched, so this is the one tab missing rather
180
+ // than the dashboard failing to draw.
181
+ expect(html).toContain("Tickets");
182
+ });
183
+
184
+ it("stays quiet while the rentals query has not answered yet", () => {
185
+ // First paint has no cached answer. Drawing the tab on a guess flashes it
186
+ // and then withdraws it from fans who never rented anything.
187
+ const html = render({ rentals: null, tab: "membership" });
188
+
189
+ expect(html).not.toContain("Rentals");
190
+ });
191
+
192
+ it("keeps the tab drawn when it is the one being shown, even with nothing in it", () => {
193
+ // A receipt email links straight at `?tab=rentals`. A strip that disowns the
194
+ // panel underneath it is worse than an empty tab.
195
+ const html = render({ rentals: [], tab: "rentals" });
196
+
197
+ expect(html).toContain("Rentals");
198
+ expect(html).toContain("You have not rented anything yet.");
199
+ });
200
+ });
@@ -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
+ });
@@ -84,6 +84,9 @@
84
84
  .order-1 {
85
85
  order: 1;
86
86
  }
87
+ .order-2 {
88
+ order: 2;
89
+ }
87
90
  .container {
88
91
  width: 100%;
89
92
  @media (width >= 40rem) {