Skip to content

Commit 037f25a

Browse files
authored
chore(clerk-js,types): Update PricingTable with trial info (#6493)
1 parent f8b38b7 commit 037f25a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+332
-9
lines changed

.changeset/tender-planets-win.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/localizations': minor
3+
'@clerk/clerk-js': minor
4+
'@clerk/types': minor
5+
---
6+
7+
Update PricingTable with trial info.

packages/clerk-js/src/core/resources/CommercePlan.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export class CommercePlan extends BaseResource implements CommercePlanResource {
2727
slug!: string;
2828
avatarUrl!: string;
2929
features!: CommerceFeature[];
30+
freeTrialDays!: number | null;
31+
freeTrialEnabled!: boolean;
3032

3133
constructor(data: CommercePlanJSON) {
3234
super();
@@ -56,6 +58,8 @@ export class CommercePlan extends BaseResource implements CommercePlanResource {
5658
this.publiclyVisible = data.publicly_visible;
5759
this.slug = data.slug;
5860
this.avatarUrl = data.avatar_url;
61+
this.freeTrialDays = this.withDefault(data.free_trial_days, null);
62+
this.freeTrialEnabled = this.withDefault(data.free_trial_enabled, false);
5963
this.features = (data.features || []).map(feature => new CommerceFeature(feature));
6064

6165
return this;

packages/clerk-js/src/core/resources/CommerceSubscription.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export class CommerceSubscriptionItem extends BaseResource implements CommerceSu
7373
credit?: {
7474
amount: CommerceMoney;
7575
};
76-
isFreeTrial = false;
76+
isFreeTrial!: boolean;
7777

7878
constructor(data: CommerceSubscriptionItemJSON) {
7979
super();

packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ function Card(props: CardProps) {
147147
} else if (planPeriod !== subscription.planPeriod && plan.annualMonthlyAmount > 0) {
148148
shouldShowFooter = true;
149149
shouldShowFooterNotice = false;
150+
} else if (plan.freeTrialEnabled && subscription.isFreeTrial) {
151+
shouldShowFooter = true;
152+
shouldShowFooterNotice = true;
150153
} else {
151154
shouldShowFooter = false;
152155
shouldShowFooterNotice = false;
@@ -232,9 +235,13 @@ function Card(props: CardProps) {
232235
<Text
233236
elementDescriptor={descriptors.pricingTableCardFooterNotice}
234237
variant={isCompact ? 'buttonSmall' : 'buttonLarge'}
235-
localizationKey={localizationKeys('badge__startsAt', {
236-
date: subscription?.periodStart,
237-
})}
238+
localizationKey={
239+
plan.freeTrialEnabled && subscription.isFreeTrial && subscription.periodEnd
240+
? localizationKeys('badge__trialEndsAt', {
241+
date: subscription.periodEnd,
242+
})
243+
: localizationKeys('badge__startsAt', { date: subscription?.periodStart })
244+
}
238245
colorScheme='secondary'
239246
sx={t => ({
240247
paddingBlock: t.space.$1x5,
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { render, waitFor } from '../../../../testUtils';
2+
import { bindCreateFixtures } from '../../../utils/test/createFixtures';
3+
import { PricingTable } from '..';
4+
5+
const { createFixtures } = bindCreateFixtures('PricingTable');
6+
7+
describe('PricingTable - trial info', () => {
8+
const trialPlan = {
9+
id: 'plan_trial',
10+
name: 'Pro',
11+
amount: 2000,
12+
amountFormatted: '20.00',
13+
annualAmount: 20000,
14+
annualAmountFormatted: '200.00',
15+
annualMonthlyAmount: 1667,
16+
annualMonthlyAmountFormatted: '16.67',
17+
currencySymbol: '$',
18+
description: 'Pro plan with trial',
19+
hasBaseFee: true,
20+
isRecurring: true,
21+
currency: 'USD',
22+
isDefault: false,
23+
forPayerType: 'user',
24+
publiclyVisible: true,
25+
slug: 'pro',
26+
avatarUrl: '',
27+
features: [] as any[],
28+
freeTrialEnabled: true,
29+
freeTrialDays: 14,
30+
__internal_toSnapshot: jest.fn(),
31+
pathRoot: '',
32+
reload: jest.fn(),
33+
} as const;
34+
35+
it('shows footer notice with trial end date when active subscription is in free trial', async () => {
36+
const { wrapper, fixtures, props } = await createFixtures(f => {
37+
f.withUser({ email_addresses: ['[email protected]'] });
38+
});
39+
40+
// Provide empty props to the PricingTable context
41+
props.setProps({});
42+
43+
fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [trialPlan as any], total_count: 1 });
44+
fixtures.clerk.billing.getSubscription.mockResolvedValue({
45+
id: 'sub_1',
46+
status: 'active',
47+
activeAt: new Date('2021-01-01'),
48+
createdAt: new Date('2021-01-01'),
49+
nextPayment: null,
50+
pastDueAt: null,
51+
updatedAt: null,
52+
subscriptionItems: [
53+
{
54+
id: 'si_1',
55+
plan: trialPlan,
56+
createdAt: new Date('2021-01-01'),
57+
paymentSourceId: 'src_1',
58+
pastDueAt: null,
59+
canceledAt: null,
60+
periodStart: new Date('2021-01-01'),
61+
periodEnd: new Date('2021-01-15'),
62+
planPeriod: 'month' as const,
63+
status: 'active' as const,
64+
isFreeTrial: true,
65+
cancel: jest.fn(),
66+
pathRoot: '',
67+
reload: jest.fn(),
68+
},
69+
],
70+
pathRoot: '',
71+
reload: jest.fn(),
72+
});
73+
74+
const { findByRole, getByText, userEvent } = render(<PricingTable />, { wrapper });
75+
76+
// Wait for the plan to appear
77+
await findByRole('heading', { name: 'Pro' });
78+
79+
// Default period is annual in mounted mode; switch to monthly to match the subscription
80+
const periodSwitch = await findByRole('switch', { name: /billed annually/i });
81+
await userEvent.click(periodSwitch);
82+
83+
await waitFor(() => {
84+
// Trial footer notice uses badge__trialEndsAt localization (short date format)
85+
expect(getByText('Trial ends Jan 15, 2021')).toBeVisible();
86+
});
87+
});
88+
89+
it('shows CTA "Start N-day free trial" when eligible and plan has trial', async () => {
90+
const { wrapper, fixtures, props } = await createFixtures(f => {
91+
f.withUser({ email_addresses: ['[email protected]'] });
92+
});
93+
94+
// Provide empty props to the PricingTable context
95+
props.setProps({});
96+
97+
fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [trialPlan as any], total_count: 1 });
98+
fixtures.clerk.billing.getSubscription.mockResolvedValue({
99+
id: 'sub_top',
100+
status: 'active',
101+
activeAt: new Date('2021-01-01'),
102+
createdAt: new Date('2021-01-01'),
103+
nextPayment: null,
104+
pastDueAt: null,
105+
updatedAt: null,
106+
eligibleForFreeTrial: true,
107+
// No subscription items for the trial plan yet
108+
subscriptionItems: [],
109+
pathRoot: '',
110+
reload: jest.fn(),
111+
});
112+
113+
const { getByRole, getByText } = render(<PricingTable />, { wrapper });
114+
115+
await waitFor(() => {
116+
expect(getByRole('heading', { name: 'Pro' })).toBeVisible();
117+
// Button text from Plans.buttonPropsForPlan via freeTrialOr
118+
expect(getByText('Start 14-day free trial')).toBeVisible();
119+
});
120+
});
121+
122+
it('shows CTA "Start N-day free trial" when user is signed out and plan has trial', async () => {
123+
const { wrapper, fixtures, props } = await createFixtures();
124+
125+
// Provide empty props to the PricingTable context
126+
props.setProps({});
127+
128+
fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [trialPlan as any], total_count: 1 });
129+
// When signed out, getSubscription should throw or return empty response
130+
fixtures.clerk.billing.getSubscription.mockRejectedValue(new Error('Unauthenticated'));
131+
132+
const { getByRole, getByText } = render(<PricingTable />, { wrapper });
133+
134+
await waitFor(() => {
135+
expect(getByRole('heading', { name: 'Pro' })).toBeVisible();
136+
// Signed out users should see free trial CTA when plan has trial enabled
137+
expect(getByText('Start 14-day free trial')).toBeVisible();
138+
});
139+
});
140+
141+
it('shows CTA "Subscribe" when user is signed out and plan has no trial', async () => {
142+
const { wrapper, fixtures, props } = await createFixtures();
143+
144+
const nonTrialPlan = {
145+
...trialPlan,
146+
id: 'plan_no_trial',
147+
name: 'Basic',
148+
freeTrialEnabled: false,
149+
freeTrialDays: 0,
150+
};
151+
152+
// Provide empty props to the PricingTable context
153+
props.setProps({});
154+
155+
fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [nonTrialPlan as any], total_count: 1 });
156+
// When signed out, getSubscription should throw or return empty response
157+
fixtures.clerk.billing.getSubscription.mockRejectedValue(new Error('Unauthenticated'));
158+
159+
const { getByRole, getByText } = render(<PricingTable />, { wrapper });
160+
161+
await waitFor(() => {
162+
expect(getByRole('heading', { name: 'Basic' })).toBeVisible();
163+
// Signed out users should see regular "Subscribe" for non-trial plans
164+
expect(getByText('Subscribe')).toBeVisible();
165+
});
166+
});
167+
});

packages/clerk-js/src/ui/contexts/components/Plans.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ export const usePlansContext = () => {
108108
return false;
109109
}, [clerk, subscriberType]);
110110

111-
const { subscriptionItems, revalidate: revalidateSubscriptions } = useSubscription();
111+
const { subscriptionItems, revalidate: revalidateSubscriptions, data: topLevelSubscription } = useSubscription();
112112

113113
// Invalidates cache but does not fetch immediately
114114
const { data: plans, revalidate: revalidatePlans } = usePlans({ mode: 'cache' });
@@ -187,6 +187,7 @@ export const usePlansContext = () => {
187187
const buttonPropsForPlan = useCallback(
188188
({
189189
plan,
190+
// TODO(@COMMERCE): This needs to be removed.
190191
subscription: sub,
191192
isCompact = false,
192193
selectedPlanPeriod = 'annual',
@@ -211,6 +212,19 @@ export const usePlansContext = () => {
211212

212213
const isEligibleForSwitchToAnnual = (plan?.annualMonthlyAmount ?? 0) > 0;
213214

215+
const freeTrialOr = (localizationKey: LocalizationKey): LocalizationKey => {
216+
if (plan?.freeTrialEnabled) {
217+
// Show trial CTA if user is signed out OR if signed in and eligible for free trial
218+
const isSignedOut = !session;
219+
const isEligibleForTrial = topLevelSubscription?.eligibleForFreeTrial;
220+
221+
if (isSignedOut || isEligibleForTrial) {
222+
return localizationKeys('commerce.startFreeTrial', { days: plan.freeTrialDays ?? 0 });
223+
}
224+
}
225+
return localizationKey;
226+
};
227+
214228
const getLocalizationKey = () => {
215229
// Handle subscription cases
216230
if (subscription) {
@@ -246,20 +260,21 @@ export const usePlansContext = () => {
246260
// Handle non-subscription cases
247261
const hasNonDefaultSubscriptions =
248262
subscriptionItems.filter(subscription => !subscription.plan.isDefault).length > 0;
263+
249264
return hasNonDefaultSubscriptions
250265
? localizationKeys('commerce.switchPlan')
251-
: localizationKeys('commerce.subscribe');
266+
: freeTrialOr(localizationKeys('commerce.subscribe'));
252267
};
253268

254269
return {
255-
localizationKey: getLocalizationKey(),
270+
localizationKey: freeTrialOr(getLocalizationKey()),
256271
variant: isCompact ? 'bordered' : 'solid',
257272
colorScheme: isCompact ? 'secondary' : 'primary',
258273
isDisabled: !canManageBilling,
259274
disabled: !canManageBilling,
260275
};
261276
},
262-
[activeOrUpcomingSubscriptionWithPlanPeriod, canManageBilling, subscriptionItems],
277+
[activeOrUpcomingSubscriptionWithPlanPeriod, canManageBilling, subscriptionItems, topLevelSubscription],
263278
);
264279

265280
const captionForSubscription = useCallback((subscription: CommerceSubscriptionItemResource) => {

packages/localizations/src/ar-SA.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export const arSA: LocalizationResource = {
6262
badge__requiresAction: 'الإجراء المطلوب',
6363
badge__startsAt: undefined,
6464
badge__thisDevice: 'هذا الجهاز',
65+
badge__trialEndsAt: undefined,
6566
badge__unverified: 'لم يتم التحقق منه',
6667
badge__upcomingPlan: undefined,
6768
badge__userDevice: 'جهاز المستخدم',
@@ -133,6 +134,7 @@ export const arSA: LocalizationResource = {
133134
},
134135
reSubscribe: undefined,
135136
seeAllFeatures: undefined,
137+
startFreeTrial: undefined,
136138
subscribe: undefined,
137139
subscriptionDetails: {
138140
beginsOn: undefined,

packages/localizations/src/be-BY.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export const beBY: LocalizationResource = {
6262
badge__requiresAction: 'Патрабуецца дзеянне',
6363
badge__startsAt: undefined,
6464
badge__thisDevice: 'Гэта прылада',
65+
badge__trialEndsAt: undefined,
6566
badge__unverified: 'Не верыфікавана',
6667
badge__upcomingPlan: undefined,
6768
badge__userDevice: 'Карыстальніцкая прылада',
@@ -133,6 +134,7 @@ export const beBY: LocalizationResource = {
133134
},
134135
reSubscribe: undefined,
135136
seeAllFeatures: undefined,
137+
startFreeTrial: undefined,
136138
subscribe: undefined,
137139
subscriptionDetails: {
138140
beginsOn: undefined,

packages/localizations/src/bg-BG.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export const bgBG: LocalizationResource = {
6262
badge__requiresAction: 'Изисква действие',
6363
badge__startsAt: undefined,
6464
badge__thisDevice: 'Това устройство',
65+
badge__trialEndsAt: undefined,
6566
badge__unverified: 'Непотвърден',
6667
badge__upcomingPlan: undefined,
6768
badge__userDevice: 'Потребителско устройство',
@@ -133,6 +134,7 @@ export const bgBG: LocalizationResource = {
133134
},
134135
reSubscribe: undefined,
135136
seeAllFeatures: undefined,
137+
startFreeTrial: undefined,
136138
subscribe: undefined,
137139
subscriptionDetails: {
138140
beginsOn: undefined,

packages/localizations/src/bn-IN.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export const bnIN: LocalizationResource = {
6262
badge__requiresAction: 'কর্ম প্রয়োজন',
6363
badge__startsAt: undefined,
6464
badge__thisDevice: 'এই ডিভাইস',
65+
badge__trialEndsAt: undefined,
6566
badge__unverified: 'অযাচাই',
6667
badge__upcomingPlan: undefined,
6768
badge__userDevice: 'ব্যবহারকারীর ডিভাইস',
@@ -133,6 +134,7 @@ export const bnIN: LocalizationResource = {
133134
},
134135
reSubscribe: undefined,
135136
seeAllFeatures: undefined,
137+
startFreeTrial: undefined,
136138
subscribe: undefined,
137139
subscriptionDetails: {
138140
beginsOn: undefined,

0 commit comments

Comments
 (0)