@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.
- package/package.json +1 -1
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +12 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +74 -0
- package/src/i18n/en.json +74 -0
- package/src/server/index.ts +74 -0
- package/src/server/platformEvents.generated.ts +60 -0
- package/src/types/models.ts +127 -0
- package/src/ui/headless/index.ts +23 -0
- package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
- package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
- package/src/ui/headless/membership/membershipApplication.ts +109 -0
- package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
- package/src/ui/headless/membership/membershipTrial.ts +53 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
- package/src/ui/index.ts +9 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
- package/src/ui/payment/ForgeStripePayment.tsx +78 -7
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/styled/AccountDashboard.tsx +160 -2
- package/src/ui/styled/LoginForm.tsx +5 -3
- package/src/ui/styled/MembershipCheckout.tsx +596 -256
- package/src/ui/styled/MembershipTierCallout.tsx +17 -3
- package/src/ui/styled/MembershipTiers.tsx +148 -6
- package/src/ui/styled/ProductGrid.tsx +14 -2
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
- package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
<
|
|
114
|
-
{
|
|
115
|
-
|
|
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
|
+
});
|