@tribe-nest/forge 3.54.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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/useCoachingProducts.ts +15 -1
  3. package/src/data/queries/useCourses.ts +15 -1
  4. package/src/data/queries/useEvents.ts +12 -1
  5. package/src/data/queries/useMembership.ts +176 -4
  6. package/src/data/queries/useProducts.ts +15 -1
  7. package/src/data/queries/useSubscriptions.ts +21 -1
  8. package/src/i18n/de.json +74 -0
  9. package/src/i18n/en.json +74 -0
  10. package/src/server/index.ts +74 -0
  11. package/src/server/platformEvents.generated.ts +60 -0
  12. package/src/types/models.ts +127 -0
  13. package/src/ui/headless/index.ts +23 -0
  14. package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
  15. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
  16. package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
  17. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
  18. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
  19. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
  20. package/src/ui/headless/membership/membershipApplication.ts +109 -0
  21. package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
  22. package/src/ui/headless/membership/membershipTrial.ts +53 -0
  23. package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
  24. package/src/ui/index.ts +9 -0
  25. package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
  26. package/src/ui/payment/ForgeStripePayment.tsx +78 -7
  27. package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
  28. package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
  29. package/src/ui/payment/stripeIntentKind.ts +32 -0
  30. package/src/ui/styled/AccountDashboard.tsx +160 -2
  31. package/src/ui/styled/LoginForm.tsx +5 -3
  32. package/src/ui/styled/MembershipCheckout.tsx +596 -256
  33. package/src/ui/styled/MembershipTierCallout.tsx +17 -3
  34. package/src/ui/styled/MembershipTiers.tsx +148 -6
  35. package/src/ui/styled/ProductGrid.tsx +14 -2
  36. package/src/ui/styled/SignupForm.tsx +4 -2
  37. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
  38. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
  39. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
  40. package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
@@ -1,5 +1,5 @@
1
1
  import type { CSSProperties } from "react";
2
- import { useGetMembershipTiers } from "../../data/queries/useMembership";
2
+ import { useGetMembershipTiers, useMembershipInviteInfo } from "../../data/queries/useMembership";
3
3
  import { useForgeT } from "../../i18n";
4
4
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
5
  import { useAmountFormatter } from "../format/useFormatCurrency";
@@ -10,6 +10,13 @@ export interface MembershipTierCalloutProps {
10
10
  membershipTierId?: string;
11
11
  /** How many benefits to list before summarising the rest. Default 3. */
12
12
  maxBenefits?: number;
13
+ /**
14
+ * The membership invite the visitor is holding (read off the same redirect as
15
+ * the tier id). An invite-only tier is only listed with it, and the callout
16
+ * then also names the email the invite was sent to, so the fan signs in with
17
+ * the right account.
18
+ */
19
+ inviteToken?: string;
13
20
  className?: string;
14
21
  style?: CSSProperties;
15
22
  }
@@ -31,6 +38,7 @@ export interface MembershipTierCalloutProps {
31
38
  export function MembershipTierCallout({
32
39
  membershipTierId,
33
40
  maxBenefits = 3,
41
+ inviteToken,
34
42
  className,
35
43
  style,
36
44
  }: MembershipTierCalloutProps) {
@@ -39,7 +47,8 @@ export function MembershipTierCallout({
39
47
  const fmt = useAmountFormatter();
40
48
  // The tier list is one cached query per profile, so this shares the fetch the
41
49
  // membership page and the checkout already make rather than adding a request.
42
- const { data: tiers } = useGetMembershipTiers();
50
+ const { data: tiers } = useGetMembershipTiers(inviteToken ? { inviteToken } : {});
51
+ const { data: invite } = useMembershipInviteInfo(inviteToken);
43
52
  const tier = membershipTierId ? tiers?.find((candidate) => candidate.id === membershipTierId) : undefined;
44
53
 
45
54
  if (!tier) return null;
@@ -67,7 +76,12 @@ export function MembershipTierCallout({
67
76
  <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 8, fontFamily: tokens.headingFontFamily }}>
68
77
  {t("forge.membership_tier_callout.join_title", { tier_name: tier.name })}
69
78
  </h2>
70
- <p style={{ fontSize: 14, marginBottom: shown.length ? 12 : 0 }}>{membershipPriceLabel(tier, t, fmt)}</p>
79
+ <p style={{ fontSize: 14, marginBottom: shown.length || invite ? 12 : 0 }}>{membershipPriceLabel(tier, t, fmt)}</p>
80
+ {invite && invite.membershipTierId === tier.id && (
81
+ <p style={{ fontSize: 14, fontWeight: 600, marginBottom: shown.length ? 12 : 0 }}>
82
+ {t("forge.membership_tier_callout.invite_for_email", { email: invite.email })}
83
+ </p>
84
+ )}
71
85
  {!!shown.length && (
72
86
  <div style={{ fontSize: 14 }}>
73
87
  <p style={{ fontWeight: 500, marginBottom: 4 }}>{t("forge.membership_tier_callout.benefits_label")}</p>
@@ -1,10 +1,18 @@
1
1
  import type { MembershipTier } from "../../types/models";
2
- import { useGetMembershipTiers } from "../../data/queries/useMembership";
2
+ import { useContext, useState } from "react";
3
+ import {
4
+ readMembershipInviteFromUrl,
5
+ useGetMembershipTiers,
6
+ useWithdrawMembershipApplication,
7
+ } from "../../data/queries/useMembership";
8
+ import { tierCardAction } from "../headless/membership/membershipApplication";
9
+ import { tierTrialDays } from "../headless/membership/membershipTrial";
3
10
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
11
  import { useAmountFormatter } from "../format/useFormatCurrency";
5
12
  import { priceTaxCaption } from "../format/PriceDisplay";
6
13
  import { usePricesIncludeTax } from "../../data/queries/useWebsite";
7
14
  import { useForgeT } from "../../i18n";
15
+ import { PublicAuthContext } from "../../contexts/PublicAuthContext";
8
16
  import { membershipPriceLabel } from "../format/membershipPriceLabel";
9
17
  import { Button } from "./Button";
10
18
  import { Loading } from "./Loading";
@@ -17,7 +25,12 @@ export interface MembershipTiersProps {
17
25
  * `membershipTierId` query param. Default `/i/membership/checkout`.
18
26
  */
19
27
  checkoutPath?: string;
20
- /** Optional override for tier selection (e.g. router navigation). */
28
+ /**
29
+ * Optional override for tier selection (e.g. router navigation). Also what
30
+ * "Complete your membership" calls on an approved application whose card was
31
+ * declined. A host that overrides it should carry an `invite` search param
32
+ * through to the checkout.
33
+ */
21
34
  onSelect?: (tier: MembershipTier) => void;
22
35
  /** Optional page heading. Pass `null` to omit. Default `"Membership"`. */
23
36
  heading?: string | null;
@@ -30,6 +43,15 @@ export interface MembershipTiersProps {
30
43
  * responsive card row (a single tier sits at a natural card width rather than
31
44
  * stretching full-screen). "Select" routes to `checkoutPath` with the tier id,
32
45
  * or calls `onSelect` when provided.
46
+ *
47
+ * Curated access: an invite-only tier is only returned when the page was opened
48
+ * with its invite (`?invite=`), and reads "Accept invite"; an application tier
49
+ * reads "Apply to join". When the signed-in fan already applied, the card shows
50
+ * where that application stands instead of the button.
51
+ *
52
+ * Free trials: a paid tier with a trial the fan is still eligible for carries a
53
+ * "{days}-day free trial" badge, and an open tier's button reads "Start free
54
+ * trial". An application tier keeps "Apply to join" (approval starts the trial).
33
55
  */
34
56
  export function MembershipTiers({
35
57
  formatAmount,
@@ -46,6 +68,8 @@ export function MembershipTiers({
46
68
  const pricesIncludeTax = usePricesIncludeTax();
47
69
  const taxCaption = priceTaxCaption({ pricesIncludeTax });
48
70
  const { data: tiers, isLoading } = useGetMembershipTiers();
71
+ const withdraw = useWithdrawMembershipApplication();
72
+ const [withdrawError, setWithdrawError] = useState<string | null>(null);
49
73
  // `null` still means "no heading"; anything else falls back to the default.
50
74
  const resolvedHeading = heading === null ? null : (heading ?? t("forge.membership_tiers.heading"));
51
75
  const freeLabel = t("forge.membership_tiers.price_free");
@@ -62,7 +86,10 @@ export function MembershipTiers({
62
86
  if (onSelect) return onSelect(tier);
63
87
  if (typeof window !== "undefined") {
64
88
  const sep = checkoutPath.includes("?") ? "&" : "?";
65
- window.location.href = `${checkoutPath}${sep}membershipTierId=${encodeURIComponent(tier.id)}`;
89
+ const invite = readMembershipInviteFromUrl();
90
+ window.location.href = `${checkoutPath}${sep}membershipTierId=${encodeURIComponent(tier.id)}${
91
+ invite ? `&invite=${encodeURIComponent(invite)}` : ""
92
+ }`;
66
93
  }
67
94
  };
68
95
 
@@ -99,6 +126,7 @@ export function MembershipTiers({
99
126
  </span>
100
127
  )}
101
128
  </div>
129
+ <TierTrialBadge tier={tier} />
102
130
  {tier.description && <p style={{ opacity: 0.75 }}>{tier.description}</p>}
103
131
  {!!tier.benefits?.length && (
104
132
  <ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 6 }}>
@@ -110,12 +138,126 @@ export function MembershipTiers({
110
138
  ))}
111
139
  </ul>
112
140
  )}
113
- <Button fullWidth style={{ marginTop: "auto" }} onClick={() => select(tier)}>
114
- {t("forge.membership_tiers.select")}
115
- </Button>
141
+ <TierAction
142
+ tier={tier}
143
+ onSelect={() => select(tier)}
144
+ onWithdraw={async (applicationId) => {
145
+ setWithdrawError(null);
146
+ try {
147
+ await withdraw.mutateAsync({ applicationId });
148
+ } catch (error) {
149
+ const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
150
+ setWithdrawError(detail || t("forge.membership_tiers.withdraw_error"));
151
+ }
152
+ }}
153
+ isWithdrawing={withdraw.isPending}
154
+ withdrawError={withdraw.variables?.applicationId === tier.myApplication?.id ? withdrawError : null}
155
+ />
116
156
  </div>
117
157
  ))}
118
158
  </div>
119
159
  </div>
120
160
  );
121
161
  }
162
+
163
+ /** "{days}-day free trial" on a tier that gives this fan a trial; nothing otherwise. Exported for the spec. */
164
+ export function TierTrialBadge({ tier }: { tier: MembershipTier }) {
165
+ const t = useForgeT();
166
+ const tokens = useThemeTokens();
167
+ // Read softly: the card also renders outside a signed-in tree.
168
+ const user = useContext(PublicAuthContext)?.user;
169
+ const days = tierTrialDays(tier, user?.membership);
170
+ if (days <= 0) return null;
171
+ return (
172
+ <span
173
+ data-testid="membership-tier-trial-badge"
174
+ style={{
175
+ alignSelf: "flex-start",
176
+ padding: "2px 10px",
177
+ borderRadius: 999,
178
+ fontSize: 12,
179
+ fontWeight: 700,
180
+ border: `1px solid ${tokens.primary}`,
181
+ color: tokens.primary,
182
+ }}
183
+ >
184
+ {t("forge.membership_tiers.trial_badge", { days })}
185
+ </span>
186
+ );
187
+ }
188
+
189
+ /**
190
+ * The bottom of a tier card: the join button in the tier's own words, or where
191
+ * the fan's application stands. Exported for the card-state spec.
192
+ */
193
+ export function TierAction({
194
+ tier,
195
+ onSelect,
196
+ onWithdraw,
197
+ isWithdrawing,
198
+ withdrawError,
199
+ }: {
200
+ tier: MembershipTier;
201
+ onSelect: () => void;
202
+ onWithdraw: (applicationId: string) => void;
203
+ isWithdrawing?: boolean;
204
+ withdrawError?: string | null;
205
+ }) {
206
+ const t = useForgeT();
207
+ const tokens = useThemeTokens();
208
+ // Read softly: the card also renders outside a signed-in tree.
209
+ const user = useContext(PublicAuthContext)?.user;
210
+ const action = tierCardAction(tier);
211
+ const stateLine = (text: string) => (
212
+ <p data-testid="membership-tier-application-state" style={{ fontWeight: 600, color: tokens.text }}>
213
+ {text}
214
+ </p>
215
+ );
216
+
217
+ switch (action) {
218
+ case "pending":
219
+ return (
220
+ <div style={{ marginTop: "auto", display: "flex", flexDirection: "column", gap: 8 }}>
221
+ {stateLine(t("forge.membership_tiers.application_pending"))}
222
+ <Button
223
+ fullWidth
224
+ variant="outline"
225
+ disabled={isWithdrawing}
226
+ onClick={() => tier.myApplication && onWithdraw(tier.myApplication.id)}
227
+ >
228
+ {isWithdrawing ? t("forge.membership_tiers.withdrawing") : t("forge.membership_tiers.withdraw")}
229
+ </Button>
230
+ {withdrawError && (
231
+ <p role="alert" style={{ fontSize: 13 }}>
232
+ {withdrawError}
233
+ </p>
234
+ )}
235
+ </div>
236
+ );
237
+ case "approved":
238
+ return <div style={{ marginTop: "auto" }}>{stateLine(t("forge.membership_tiers.application_approved"))}</div>;
239
+ case "declined":
240
+ return <div style={{ marginTop: "auto" }}>{stateLine(t("forge.membership_tiers.application_declined"))}</div>;
241
+ case "complete":
242
+ return (
243
+ <div style={{ marginTop: "auto", display: "flex", flexDirection: "column", gap: 8 }}>
244
+ {stateLine(t("forge.membership_tiers.application_payment_failed"))}
245
+ <Button fullWidth onClick={onSelect}>
246
+ {t("forge.membership_tiers.complete_membership")}
247
+ </Button>
248
+ </div>
249
+ );
250
+ default:
251
+ return (
252
+ <Button fullWidth style={{ marginTop: "auto" }} onClick={onSelect}>
253
+ {action === "accept_invite"
254
+ ? t("forge.membership_tiers.accept_invite")
255
+ : action === "apply"
256
+ ? t("forge.membership_tiers.apply")
257
+ : tierTrialDays(tier, user?.membership) > 0
258
+ ? t("forge.membership_tiers.start_free_trial")
259
+ : t("forge.membership_tiers.select")}
260
+ </Button>
261
+ );
262
+ }
263
+ }
@@ -1,6 +1,6 @@
1
1
  import { Lock } from "lucide-react";
2
2
  import { useGetProducts, useGetProductsByIds } from "../../data/queries/useProducts";
3
- import type { IPublicProduct, ProductType } from "../../types/models";
3
+ import type { IPublicProduct, PaginatedData, ProductType } from "../../types/models";
4
4
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
5
  import { useAmountFormatter } from "../format/useFormatCurrency";
6
6
  import { PriceDisplay } from "../format/PriceDisplay";
@@ -9,6 +9,15 @@ import { Loading } from "./Loading";
9
9
 
10
10
  export interface ProductGridProps {
11
11
  columns?: number;
12
+ /**
13
+ * The first page of the shop listing, from a server render
14
+ * (`fetchProductsServer`).
15
+ *
16
+ * Seeds the query so the grid arrives with products in it instead of
17
+ * rendering a spinner into the document. Only applies to the plain listing:
18
+ * a grid showing `productIds` is a different query and ignores it.
19
+ */
20
+ initialProducts?: PaginatedData<IPublicProduct>;
12
21
  limit?: number;
13
22
  /**
14
23
  * Show these specific products, in this order, instead of the shop listing.
@@ -49,6 +58,7 @@ function minPrice(p: IPublicProduct): number {
49
58
  /** Themed product grid, built on `useGetProducts`. */
50
59
  export function ProductGrid({
51
60
  columns = 3,
61
+ initialProducts,
52
62
  limit,
53
63
  productIds,
54
64
  categoryId,
@@ -67,7 +77,9 @@ export function ProductGrid({
67
77
  const byIds = !!productIds?.length;
68
78
  // Both hooks are called unconditionally (rules of hooks); each disables
69
79
  // itself when it isn't the one in use.
70
- const listQuery = useGetProducts({ page: 1, categoryId, collectionId, productType }, !byIds);
80
+ const listQuery = useGetProducts({ page: 1, categoryId, collectionId, productType }, !byIds, {
81
+ initialData: initialProducts,
82
+ });
71
83
  const idsQuery = useGetProductsByIds(byIds ? productIds! : []);
72
84
 
73
85
  const isLoading = byIds ? idsQuery.isLoading : listQuery.isLoading;
@@ -15,11 +15,13 @@ export interface SignupFormProps {
15
15
  * create-account request, where a coupon can turn it into a membership.
16
16
  */
17
17
  membershipTierId?: string;
18
+ /** The membership invite token riding on the same redirect, if any. See `MembershipTierCallout`. */
19
+ membershipInviteToken?: string;
18
20
  couponCode?: string;
19
21
  }
20
22
 
21
23
  /** Themed account signup form, built on `useSignupForm`. */
22
- export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode }: SignupFormProps) {
24
+ export function SignupForm({ onSuccess, loginHref, membershipTierId, membershipInviteToken, couponCode }: SignupFormProps) {
23
25
  const t = useForgeT();
24
26
  const theme = useForgeTheme();
25
27
  const form = useSignupForm({ onSuccess, membershipTierId, couponCode });
@@ -73,7 +75,7 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
73
75
  <div style={card}>
74
76
  {/* What the fan was doing when they hit this wall, kept in view through
75
77
  the emailed-code step as well. Nothing is drawn without a tier. */}
76
- <MembershipTierCallout membershipTierId={membershipTierId} />
78
+ <MembershipTierCallout membershipTierId={membershipTierId} inviteToken={membershipInviteToken} />
77
79
  <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>
78
80
  {form.awaitingCode ? t("forge.signup_form.verify_title") : t("forge.signup_form.title")}
79
81
  </h1>
@@ -0,0 +1,105 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, afterEach } from "vitest";
3
+ import { render, screen, 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 { AccountDashboard } from "../AccountDashboard";
10
+
11
+ /**
12
+ * The account page during a free trial: the date the trial ends and what is
13
+ * charged after it replace "Renews on", and cancelling says access lasts to the
14
+ * trial end with nothing charged.
15
+ */
16
+
17
+ const DAY = 24 * 60 * 60 * 1000;
18
+ const inDays = (n: number) => new Date(Date.now() + n * DAY).toISOString();
19
+ const fmt = (iso: string) =>
20
+ new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
21
+
22
+ const memberWith = (membership: Record<string, unknown>) => ({
23
+ id: "account-1",
24
+ email: "fan@example.com",
25
+ firstName: "Fan",
26
+ lastName: "Person",
27
+ kind: "fan",
28
+ status: "active",
29
+ createdAt: "2026-01-01T00:00:00.000Z",
30
+ updatedAt: "2026-01-01T00:00:00.000Z",
31
+ membership: {
32
+ id: "membership-1",
33
+ membershipTierId: "tier-1",
34
+ status: "active",
35
+ startDate: "2026-09-17T00:00:00.000Z",
36
+ subscriptionAmount: 10,
37
+ subscriptionCurrency: "USD",
38
+ billingCycle: "month",
39
+ membershipTier: { id: "tier-1", name: "Inner Circle", benefits: [] },
40
+ ...membership,
41
+ },
42
+ });
43
+
44
+ let realAdapter: AxiosAdapter | undefined;
45
+
46
+ function mount(user: unknown) {
47
+ // Every request answers empty, so the tab's side queries never reach a network.
48
+ realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
49
+ axios.defaults.adapter = (async (config: InternalAxiosRequestConfig) =>
50
+ ({ data: [], status: 200, statusText: "OK", headers: {}, config }) as never) as AxiosAdapter;
51
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
52
+ return render(
53
+ <QueryClientProvider client={queryClient}>
54
+ <ForgeClientProvider apiUrl="http://api.test" profileId="profile-1">
55
+ <PublicAuthContext.Provider
56
+ value={{ user, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}
57
+ >
58
+ <ForgeThemeProvider theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}>
59
+ <AccountDashboard tab="membership" onNavigateMembership={() => {}} />
60
+ </ForgeThemeProvider>
61
+ </PublicAuthContext.Provider>
62
+ </ForgeClientProvider>
63
+ </QueryClientProvider>,
64
+ );
65
+ }
66
+
67
+ afterEach(() => {
68
+ cleanup();
69
+ if (realAdapter) axios.defaults.adapter = realAdapter;
70
+ });
71
+
72
+ describe("AccountDashboard: a membership in its free trial", () => {
73
+ it("shows the trial end and what is charged after it, instead of Renews on", () => {
74
+ const ends = inDays(10);
75
+ mount(memberWith({ trialEndsAt: ends, endDate: ends }));
76
+
77
+ const state = screen.getByTestId("membership-trial-state");
78
+ expect(state.textContent).toContain(`Free trial until ${fmt(ends)}`);
79
+ expect(state.textContent).toMatch(new RegExp(`Then .*10.* per month from ${fmt(ends)}\\.`));
80
+ expect(document.body.textContent).not.toContain("Renews on");
81
+ });
82
+
83
+ it("says access lasts to the trial end with nothing charged when cancelling", () => {
84
+ const ends = inDays(10);
85
+ mount(memberWith({ trialEndsAt: ends, endDate: ends }));
86
+
87
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
88
+ const dialog = screen.getByRole("alertdialog");
89
+ expect(dialog.textContent).toContain(
90
+ `Your free trial will not turn into a paid membership. You keep your benefits until ${fmt(ends)}, and nothing is charged.`,
91
+ );
92
+ });
93
+
94
+ it("says nothing will be charged once a trial is cancelled", () => {
95
+ const ends = inDays(10);
96
+ mount(memberWith({ trialEndsAt: ends, endDate: ends, cancelAtPeriodEnd: true }));
97
+ expect(screen.getByTestId("membership-trial-state").textContent).toContain("Cancelled. Nothing will be charged.");
98
+ });
99
+
100
+ it("shows no trial line once the trial has ended", () => {
101
+ mount(memberWith({ trialEndsAt: inDays(-5), endDate: inDays(25) }));
102
+ expect(screen.queryByTestId("membership-trial-state")).toBeNull();
103
+ expect(document.body.textContent).toContain("Renews on");
104
+ });
105
+ });
@@ -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
+ });