Skip to content

Commit aa6a3c3

Browse files
authored
feat(clerk-js): Update SubscriptionDetails to support free trials (#6569)
1 parent bcb905b commit aa6a3c3

File tree

6 files changed

+296
-17
lines changed

6 files changed

+296
-17
lines changed

.changeset/cruel-bears-sniff.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 SubscriptionDetails to support free trials

packages/clerk-js/bundlewatch.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
{ "path": "./dist/clerk.browser.js", "maxSize": "78KB" },
55
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "119KB" },
66
{ "path": "./dist/clerk.headless*.js", "maxSize": "61KB" },
7-
{ "path": "./dist/ui-common*.js", "maxSize": "114KB" },
7+
{ "path": "./dist/ui-common*.js", "maxSize": "114.1KB" },
88
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "118KB" },
99
{ "path": "./dist/vendors*.js", "maxSize": "41KB" },
1010
{ "path": "./dist/coinbase*.js", "maxSize": "38KB" },

packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,4 +1013,219 @@ describe('SubscriptionDetails', () => {
10131013
expect(queryByRole('button', { name: /Open menu/i })).toBeNull();
10141014
});
10151015
});
1016+
1017+
it('active free trial subscription shows correct labels and behavior', async () => {
1018+
const { wrapper, fixtures } = await createFixtures(f => {
1019+
f.withUser({ email_addresses: ['test@clerk.com'] });
1020+
});
1021+
1022+
fixtures.clerk.billing.getSubscription.mockResolvedValue({
1023+
activeAt: new Date('2021-01-01'),
1024+
createdAt: new Date('2021-01-01'),
1025+
pastDueAt: null,
1026+
id: 'sub_123',
1027+
nextPayment: {
1028+
amount: {
1029+
amount: 1000,
1030+
amountFormatted: '10.00',
1031+
currency: 'USD',
1032+
currencySymbol: '$',
1033+
},
1034+
date: new Date('2021-02-01'),
1035+
},
1036+
status: 'active',
1037+
subscriptionItems: [
1038+
{
1039+
id: 'sub_123',
1040+
plan: {
1041+
id: 'plan_123',
1042+
name: 'Pro Plan',
1043+
fee: {
1044+
amount: 1000,
1045+
amountFormatted: '10.00',
1046+
currencySymbol: '$',
1047+
currency: 'USD',
1048+
},
1049+
annualFee: {
1050+
amount: 10000,
1051+
amountFormatted: '100.00',
1052+
currencySymbol: '$',
1053+
currency: 'USD',
1054+
},
1055+
annualMonthlyFee: {
1056+
amount: 8333,
1057+
amountFormatted: '83.33',
1058+
currencySymbol: '$',
1059+
currency: 'USD',
1060+
},
1061+
description: 'Pro Plan',
1062+
hasBaseFee: true,
1063+
isRecurring: true,
1064+
isDefault: false,
1065+
},
1066+
createdAt: new Date('2021-01-01'),
1067+
periodStart: new Date('2021-01-01'),
1068+
periodEnd: new Date('2021-02-01'),
1069+
canceledAt: null,
1070+
paymentSourceId: 'src_123',
1071+
planPeriod: 'month',
1072+
status: 'active',
1073+
isFreeTrial: true,
1074+
},
1075+
],
1076+
});
1077+
1078+
const { getByRole, getByText, getAllByText, queryByText, userEvent } = render(
1079+
<Drawer.Root
1080+
open
1081+
onOpenChange={() => {}}
1082+
>
1083+
<SubscriptionDetails />
1084+
</Drawer.Root>,
1085+
{ wrapper },
1086+
);
1087+
1088+
await waitFor(() => {
1089+
expect(getByRole('heading', { name: /Subscription/i })).toBeVisible();
1090+
expect(getByText('Pro Plan')).toBeVisible();
1091+
expect(getByText('Free trial')).toBeVisible();
1092+
expect(getByText('$10.00 / Month')).toBeVisible();
1093+
1094+
// Free trial specific labels
1095+
expect(getByText('Trial started on')).toBeVisible();
1096+
expect(getByText('January 1, 2021')).toBeVisible();
1097+
expect(getByText('Trial ends on')).toBeVisible();
1098+
1099+
// Should have multiple instances of February 1, 2021 (trial end and first payment)
1100+
const februaryDates = getAllByText('February 1, 2021');
1101+
expect(februaryDates.length).toBeGreaterThan(1);
1102+
1103+
// Payment related labels should use "first payment" wording
1104+
expect(getByText('First payment on')).toBeVisible();
1105+
expect(getByText('First payment amount')).toBeVisible();
1106+
expect(getByText('$10.00')).toBeVisible();
1107+
1108+
// Should not show regular subscription labels
1109+
expect(queryByText('Subscribed on')).toBeNull();
1110+
expect(queryByText('Renews at')).toBeNull();
1111+
expect(queryByText('Next payment on')).toBeNull();
1112+
expect(queryByText('Next payment amount')).toBeNull();
1113+
});
1114+
1115+
// Test the menu shows free trial specific options
1116+
const menuButton = getByRole('button', { name: /Open menu/i });
1117+
expect(menuButton).toBeVisible();
1118+
await userEvent.click(menuButton);
1119+
1120+
await waitFor(() => {
1121+
expect(getByText('Cancel free trial')).toBeVisible();
1122+
});
1123+
});
1124+
1125+
it('allows cancelling a free trial with specific dialog text', async () => {
1126+
const { wrapper, fixtures } = await createFixtures(f => {
1127+
f.withUser({ email_addresses: ['test@clerk.com'] });
1128+
});
1129+
1130+
const cancelSubscriptionMock = jest.fn().mockResolvedValue({});
1131+
1132+
fixtures.clerk.billing.getSubscription.mockResolvedValue({
1133+
activeAt: new Date('2021-01-01'),
1134+
createdAt: new Date('2021-01-01'),
1135+
pastDueAt: null,
1136+
id: 'sub_123',
1137+
nextPayment: {
1138+
amount: {
1139+
amount: 1000,
1140+
amountFormatted: '10.00',
1141+
currency: 'USD',
1142+
currencySymbol: '$',
1143+
},
1144+
date: new Date('2021-02-01'),
1145+
},
1146+
status: 'active',
1147+
subscriptionItems: [
1148+
{
1149+
id: 'sub_123',
1150+
plan: {
1151+
id: 'plan_123',
1152+
name: 'Pro Plan',
1153+
fee: {
1154+
amount: 1000,
1155+
amountFormatted: '10.00',
1156+
currencySymbol: '$',
1157+
currency: 'USD',
1158+
},
1159+
annualFee: {
1160+
amount: 10000,
1161+
amountFormatted: '100.00',
1162+
currencySymbol: '$',
1163+
currency: 'USD',
1164+
},
1165+
annualMonthlyFee: {
1166+
amount: 8333,
1167+
amountFormatted: '83.33',
1168+
currencySymbol: '$',
1169+
currency: 'USD',
1170+
},
1171+
description: 'Pro Plan',
1172+
hasBaseFee: true,
1173+
isRecurring: true,
1174+
isDefault: false,
1175+
},
1176+
createdAt: new Date('2021-01-01'),
1177+
periodStart: new Date('2021-01-01'),
1178+
periodEnd: new Date('2021-02-01'),
1179+
canceledAt: null,
1180+
paymentSourceId: 'src_123',
1181+
planPeriod: 'month',
1182+
status: 'active',
1183+
isFreeTrial: true,
1184+
cancel: cancelSubscriptionMock,
1185+
},
1186+
],
1187+
});
1188+
1189+
const { getByRole, getByText, userEvent } = render(
1190+
<Drawer.Root
1191+
open
1192+
onOpenChange={() => {}}
1193+
>
1194+
<SubscriptionDetails />
1195+
</Drawer.Root>,
1196+
{ wrapper },
1197+
);
1198+
1199+
// Wait for the subscription details to render
1200+
await waitFor(() => {
1201+
expect(getByText('Pro Plan')).toBeVisible();
1202+
expect(getByText('Free trial')).toBeVisible();
1203+
});
1204+
1205+
// Open the menu
1206+
const menuButton = getByRole('button', { name: /Open menu/i });
1207+
await userEvent.click(menuButton);
1208+
1209+
// Wait for the cancel option to appear and click it
1210+
await userEvent.click(getByText('Cancel free trial'));
1211+
1212+
await waitFor(() => {
1213+
// Should show free trial specific cancellation dialog
1214+
expect(getByText('Cancel free trial for Pro Plan plan?')).toBeVisible();
1215+
expect(
1216+
getByText(
1217+
'You’re about to cancel your free trial for the Pro Plan plan. If you cancel now, you’ll lose access to the plan’s features right away and won’t be able to start the trial again.',
1218+
),
1219+
).toBeVisible();
1220+
expect(getByText('Keep free trial')).toBeVisible();
1221+
});
1222+
1223+
// Click the cancel button in the dialog
1224+
await userEvent.click(getByText('Cancel free trial'));
1225+
1226+
// Assert that the cancelSubscription method was called
1227+
await waitFor(() => {
1228+
expect(cancelSubscriptionMock).toHaveBeenCalled();
1229+
});
1230+
});
10161231
});

packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,11 @@ const SubscriptionDetailsFooter = withCardStateProvider(() => {
243243
setError(undefined);
244244
onOpenChange(false);
245245
}}
246-
localizationKey={localizationKeys('commerce.keepSubscription')}
246+
localizationKey={
247+
selectedSubscription?.isFreeTrial
248+
? localizationKeys('commerce.keepFreeTrial')
249+
: localizationKeys('commerce.keepSubscription')
250+
}
247251
/>
248252
)}
249253
<Button
@@ -253,7 +257,13 @@ const SubscriptionDetailsFooter = withCardStateProvider(() => {
253257
textVariant='buttonLarge'
254258
isLoading={isLoading}
255259
onClick={() => void cancelSubscription()}
256-
localizationKey={localizationKeys('commerce.cancelSubscription')}
260+
localizationKey={
261+
selectedSubscription?.isFreeTrial
262+
? localizationKeys('commerce.cancelFreeTrial', {
263+
plan: selectedSubscription.plan.name,
264+
})
265+
: localizationKeys('commerce.cancelSubscription')
266+
}
257267
/>
258268
</>
259269
}
@@ -264,21 +274,31 @@ const SubscriptionDetailsFooter = withCardStateProvider(() => {
264274
elementDescriptor={descriptors.drawerConfirmationTitle}
265275
as='h2'
266276
textVariant='h3'
267-
localizationKey={localizationKeys('commerce.cancelSubscriptionTitle', {
268-
plan: `${selectedSubscription.status === 'upcoming' ? 'upcoming ' : ''}${selectedSubscription.plan.name}`,
269-
})}
277+
localizationKey={
278+
selectedSubscription?.isFreeTrial
279+
? localizationKeys('commerce.cancelFreeTrialTitle', {
280+
plan: selectedSubscription.plan.name,
281+
})
282+
: localizationKeys('commerce.cancelSubscriptionTitle', {
283+
plan: `${selectedSubscription.status === 'upcoming' ? 'upcoming ' : ''}${selectedSubscription.plan.name}`,
284+
})
285+
}
270286
/>
271287
<Text
272288
elementDescriptor={descriptors.drawerConfirmationDescription}
273289
colorScheme='secondary'
274290
localizationKey={
275-
selectedSubscription.status === 'upcoming'
276-
? localizationKeys('commerce.cancelSubscriptionNoCharge')
277-
: localizationKeys('commerce.cancelSubscriptionAccessUntil', {
291+
selectedSubscription?.isFreeTrial
292+
? localizationKeys('commerce.cancelFreeTrialDescription', {
278293
plan: selectedSubscription.plan.name,
279-
// this will always be defined in this state
280-
date: selectedSubscription.periodEnd as Date,
281294
})
295+
: selectedSubscription.status === 'upcoming'
296+
? localizationKeys('commerce.cancelSubscriptionNoCharge')
297+
: localizationKeys('commerce.cancelSubscriptionAccessUntil', {
298+
plan: selectedSubscription.plan.name,
299+
// this will always be defined in this state
300+
date: selectedSubscription.periodEnd as Date,
301+
})
282302
}
283303
/>
284304
<CardAlert>{error}</CardAlert>
@@ -303,6 +323,8 @@ function SubscriptionDetailsSummary() {
303323
return null;
304324
}
305325

326+
const { isFreeTrial } = activeSubscription;
327+
306328
return (
307329
<LineItems.Root>
308330
<LineItems.Group>
@@ -316,11 +338,19 @@ function SubscriptionDetailsSummary() {
316338
/>
317339
</LineItems.Group>
318340
<LineItems.Group>
319-
<LineItems.Title description={localizationKeys('commerce.subscriptionDetails.nextPaymentOn')} />
341+
<LineItems.Title
342+
description={localizationKeys(
343+
`commerce.subscriptionDetails.${isFreeTrial ? 'firstPaymentOn' : 'nextPaymentOn'}`,
344+
)}
345+
/>
320346
<LineItems.Description text={formatDate(subscription.nextPayment.date)} />
321347
</LineItems.Group>
322348
<LineItems.Group>
323-
<LineItems.Title description={localizationKeys('commerce.subscriptionDetails.nextPaymentAmount')} />
349+
<LineItems.Title
350+
description={localizationKeys(
351+
`commerce.subscriptionDetails.${isFreeTrial ? 'firstPaymentAmount' : 'nextPaymentAmount'}`,
352+
)}
353+
/>
324354
<LineItems.Description
325355
prefix={subscription.nextPayment.amount.currency}
326356
text={`${subscription.nextPayment.amount.currencySymbol}${subscription.nextPayment.amount.amountFormatted}`}
@@ -395,7 +425,11 @@ const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubsc
395425
isCancellable
396426
? {
397427
isDestructive: true,
398-
label: localizationKeys('commerce.cancelSubscription'),
428+
label: subscription.isFreeTrial
429+
? localizationKeys('commerce.cancelFreeTrial', {
430+
plan: subscription.plan.name,
431+
})
432+
: localizationKeys('commerce.cancelSubscription'),
399433
onClick: () => {
400434
setSubscription(subscription);
401435
setConfirmationOpen(true);
@@ -489,7 +523,7 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription
489523
{subscription.plan.name}
490524
</Text>
491525
<SubscriptionBadge
492-
subscription={subscription}
526+
subscription={subscription.isFreeTrial ? { status: 'free_trial' } : subscription}
493527
elementDescriptor={descriptors.subscriptionDetailsCardBadge}
494528
/>
495529
</Flex>
@@ -527,7 +561,11 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription
527561
{subscription.status === 'active' ? (
528562
<>
529563
<DetailRow
530-
label={localizationKeys('commerce.subscriptionDetails.subscribedOn')}
564+
label={
565+
subscription.isFreeTrial
566+
? localizationKeys('commerce.subscriptionDetails.trialStartedOn')
567+
: localizationKeys('commerce.subscriptionDetails.subscribedOn')
568+
}
531569
value={formatDate(subscription.createdAt)}
532570
/>
533571
{/* The free plan does not have a period end date */}
@@ -536,7 +574,9 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription
536574
label={
537575
subscription.canceledAt
538576
? localizationKeys('commerce.subscriptionDetails.endsOn')
539-
: localizationKeys('commerce.subscriptionDetails.renewsAt')
577+
: subscription.isFreeTrial
578+
? localizationKeys('commerce.subscriptionDetails.trialEndsOn')
579+
: localizationKeys('commerce.subscriptionDetails.renewsAt')
540580
}
541581
value={formatDate(subscription.periodEnd)}
542582
/>

0 commit comments

Comments
 (0)