Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/cruel-bears-sniff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/types': minor
---

Update SubscriptionDetails to support free trials
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{ "path": "./dist/clerk.browser.js", "maxSize": "78KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "119KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "61KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "114KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "114.1KB" },
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "118KB" },
{ "path": "./dist/vendors*.js", "maxSize": "41KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "38KB" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1013,4 +1013,219 @@ describe('SubscriptionDetails', () => {
expect(queryByRole('button', { name: /Open menu/i })).toBeNull();
});
});

it('active free trial subscription shows correct labels and behavior', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withUser({ email_addresses: ['test@clerk.com'] });
});

fixtures.clerk.billing.getSubscription.mockResolvedValue({
activeAt: new Date('2021-01-01'),
createdAt: new Date('2021-01-01'),
pastDueAt: null,
id: 'sub_123',
nextPayment: {
amount: {
amount: 1000,
amountFormatted: '10.00',
currency: 'USD',
currencySymbol: '$',
},
date: new Date('2021-02-01'),
},
status: 'active',
subscriptionItems: [
{
id: 'sub_123',
plan: {
id: 'plan_123',
name: 'Pro Plan',
fee: {
amount: 1000,
amountFormatted: '10.00',
currencySymbol: '$',
currency: 'USD',
},
annualFee: {
amount: 10000,
amountFormatted: '100.00',
currencySymbol: '$',
currency: 'USD',
},
annualMonthlyFee: {
amount: 8333,
amountFormatted: '83.33',
currencySymbol: '$',
currency: 'USD',
},
description: 'Pro Plan',
hasBaseFee: true,
isRecurring: true,
isDefault: false,
},
createdAt: new Date('2021-01-01'),
periodStart: new Date('2021-01-01'),
periodEnd: new Date('2021-02-01'),
canceledAt: null,
paymentSourceId: 'src_123',
planPeriod: 'month',
status: 'active',
isFreeTrial: true,
},
],
});

const { getByRole, getByText, getAllByText, queryByText, userEvent } = render(
<Drawer.Root
open
onOpenChange={() => {}}
>
<SubscriptionDetails />
</Drawer.Root>,
{ wrapper },
);

await waitFor(() => {
expect(getByRole('heading', { name: /Subscription/i })).toBeVisible();
expect(getByText('Pro Plan')).toBeVisible();
expect(getByText('Free trial')).toBeVisible();
expect(getByText('$10.00 / Month')).toBeVisible();

// Free trial specific labels
expect(getByText('Trial started on')).toBeVisible();
expect(getByText('January 1, 2021')).toBeVisible();
expect(getByText('Trial ends on')).toBeVisible();

// Should have multiple instances of February 1, 2021 (trial end and first payment)
const februaryDates = getAllByText('February 1, 2021');
expect(februaryDates.length).toBeGreaterThan(1);

// Payment related labels should use "first payment" wording
expect(getByText('First payment on')).toBeVisible();
expect(getByText('First payment amount')).toBeVisible();
expect(getByText('$10.00')).toBeVisible();

// Should not show regular subscription labels
expect(queryByText('Subscribed on')).toBeNull();
expect(queryByText('Renews at')).toBeNull();
expect(queryByText('Next payment on')).toBeNull();
expect(queryByText('Next payment amount')).toBeNull();
});

// Test the menu shows free trial specific options
const menuButton = getByRole('button', { name: /Open menu/i });
expect(menuButton).toBeVisible();
await userEvent.click(menuButton);

await waitFor(() => {
expect(getByText('Cancel free trial')).toBeVisible();
});
});

it('allows cancelling a free trial with specific dialog text', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withUser({ email_addresses: ['test@clerk.com'] });
});

const cancelSubscriptionMock = jest.fn().mockResolvedValue({});

fixtures.clerk.billing.getSubscription.mockResolvedValue({
activeAt: new Date('2021-01-01'),
createdAt: new Date('2021-01-01'),
pastDueAt: null,
id: 'sub_123',
nextPayment: {
amount: {
amount: 1000,
amountFormatted: '10.00',
currency: 'USD',
currencySymbol: '$',
},
date: new Date('2021-02-01'),
},
status: 'active',
subscriptionItems: [
{
id: 'sub_123',
plan: {
id: 'plan_123',
name: 'Pro Plan',
fee: {
amount: 1000,
amountFormatted: '10.00',
currencySymbol: '$',
currency: 'USD',
},
annualFee: {
amount: 10000,
amountFormatted: '100.00',
currencySymbol: '$',
currency: 'USD',
},
annualMonthlyFee: {
amount: 8333,
amountFormatted: '83.33',
currencySymbol: '$',
currency: 'USD',
},
description: 'Pro Plan',
hasBaseFee: true,
isRecurring: true,
isDefault: false,
},
createdAt: new Date('2021-01-01'),
periodStart: new Date('2021-01-01'),
periodEnd: new Date('2021-02-01'),
canceledAt: null,
paymentSourceId: 'src_123',
planPeriod: 'month',
status: 'active',
isFreeTrial: true,
cancel: cancelSubscriptionMock,
},
],
});

const { getByRole, getByText, userEvent } = render(
<Drawer.Root
open
onOpenChange={() => {}}
>
<SubscriptionDetails />
</Drawer.Root>,
{ wrapper },
);

// Wait for the subscription details to render
await waitFor(() => {
expect(getByText('Pro Plan')).toBeVisible();
expect(getByText('Free trial')).toBeVisible();
});

// Open the menu
const menuButton = getByRole('button', { name: /Open menu/i });
await userEvent.click(menuButton);

// Wait for the cancel option to appear and click it
await userEvent.click(getByText('Cancel free trial'));

await waitFor(() => {
// Should show free trial specific cancellation dialog
expect(getByText('Cancel free trial for Pro Plan plan?')).toBeVisible();
expect(
getByText(
'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.',
),
).toBeVisible();
expect(getByText('Keep free trial')).toBeVisible();
});

// Click the cancel button in the dialog
await userEvent.click(getByText('Cancel free trial'));

// Assert that the cancelSubscription method was called
await waitFor(() => {
expect(cancelSubscriptionMock).toHaveBeenCalled();
});
});
});
72 changes: 56 additions & 16 deletions packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,11 @@ const SubscriptionDetailsFooter = withCardStateProvider(() => {
setError(undefined);
onOpenChange(false);
}}
localizationKey={localizationKeys('commerce.keepSubscription')}
localizationKey={
selectedSubscription?.isFreeTrial
? localizationKeys('commerce.keepFreeTrial')
: localizationKeys('commerce.keepSubscription')
}
/>
)}
<Button
Expand All @@ -253,7 +257,13 @@ const SubscriptionDetailsFooter = withCardStateProvider(() => {
textVariant='buttonLarge'
isLoading={isLoading}
onClick={() => void cancelSubscription()}
localizationKey={localizationKeys('commerce.cancelSubscription')}
localizationKey={
selectedSubscription?.isFreeTrial
? localizationKeys('commerce.cancelFreeTrial', {
plan: selectedSubscription.plan.name,
})
: localizationKeys('commerce.cancelSubscription')
}
/>
</>
}
Expand All @@ -264,21 +274,31 @@ const SubscriptionDetailsFooter = withCardStateProvider(() => {
elementDescriptor={descriptors.drawerConfirmationTitle}
as='h2'
textVariant='h3'
localizationKey={localizationKeys('commerce.cancelSubscriptionTitle', {
plan: `${selectedSubscription.status === 'upcoming' ? 'upcoming ' : ''}${selectedSubscription.plan.name}`,
})}
localizationKey={
selectedSubscription?.isFreeTrial
? localizationKeys('commerce.cancelFreeTrialTitle', {
plan: selectedSubscription.plan.name,
})
: localizationKeys('commerce.cancelSubscriptionTitle', {
plan: `${selectedSubscription.status === 'upcoming' ? 'upcoming ' : ''}${selectedSubscription.plan.name}`,
})
}
/>
<Text
elementDescriptor={descriptors.drawerConfirmationDescription}
colorScheme='secondary'
localizationKey={
selectedSubscription.status === 'upcoming'
? localizationKeys('commerce.cancelSubscriptionNoCharge')
: localizationKeys('commerce.cancelSubscriptionAccessUntil', {
selectedSubscription?.isFreeTrial
? localizationKeys('commerce.cancelFreeTrialDescription', {
plan: selectedSubscription.plan.name,
// this will always be defined in this state
date: selectedSubscription.periodEnd as Date,
})
: selectedSubscription.status === 'upcoming'
? localizationKeys('commerce.cancelSubscriptionNoCharge')
: localizationKeys('commerce.cancelSubscriptionAccessUntil', {
plan: selectedSubscription.plan.name,
// this will always be defined in this state
date: selectedSubscription.periodEnd as Date,
})
}
/>
<CardAlert>{error}</CardAlert>
Expand All @@ -303,6 +323,8 @@ function SubscriptionDetailsSummary() {
return null;
}

const { isFreeTrial } = activeSubscription;

return (
<LineItems.Root>
<LineItems.Group>
Expand All @@ -316,11 +338,19 @@ function SubscriptionDetailsSummary() {
/>
</LineItems.Group>
<LineItems.Group>
<LineItems.Title description={localizationKeys('commerce.subscriptionDetails.nextPaymentOn')} />
<LineItems.Title
description={localizationKeys(
`commerce.subscriptionDetails.${isFreeTrial ? 'firstPaymentOn' : 'nextPaymentOn'}`,
)}
/>
<LineItems.Description text={formatDate(subscription.nextPayment.date)} />
</LineItems.Group>
<LineItems.Group>
<LineItems.Title description={localizationKeys('commerce.subscriptionDetails.nextPaymentAmount')} />
<LineItems.Title
description={localizationKeys(
`commerce.subscriptionDetails.${isFreeTrial ? 'firstPaymentAmount' : 'nextPaymentAmount'}`,
)}
/>
<LineItems.Description
prefix={subscription.nextPayment.amount.currency}
text={`${subscription.nextPayment.amount.currencySymbol}${subscription.nextPayment.amount.amountFormatted}`}
Expand Down Expand Up @@ -395,7 +425,11 @@ const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubsc
isCancellable
? {
isDestructive: true,
label: localizationKeys('commerce.cancelSubscription'),
label: subscription.isFreeTrial
? localizationKeys('commerce.cancelFreeTrial', {
plan: subscription.plan.name,
})
: localizationKeys('commerce.cancelSubscription'),
onClick: () => {
setSubscription(subscription);
setConfirmationOpen(true);
Expand Down Expand Up @@ -489,7 +523,7 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription
{subscription.plan.name}
</Text>
<SubscriptionBadge
subscription={subscription}
subscription={subscription.isFreeTrial ? { status: 'free_trial' } : subscription}
elementDescriptor={descriptors.subscriptionDetailsCardBadge}
/>
</Flex>
Expand Down Expand Up @@ -527,7 +561,11 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription
{subscription.status === 'active' ? (
<>
<DetailRow
label={localizationKeys('commerce.subscriptionDetails.subscribedOn')}
label={
subscription.isFreeTrial
? localizationKeys('commerce.subscriptionDetails.trialStartedOn')
: localizationKeys('commerce.subscriptionDetails.subscribedOn')
}
value={formatDate(subscription.createdAt)}
/>
{/* The free plan does not have a period end date */}
Expand All @@ -536,7 +574,9 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription
label={
subscription.canceledAt
? localizationKeys('commerce.subscriptionDetails.endsOn')
: localizationKeys('commerce.subscriptionDetails.renewsAt')
: subscription.isFreeTrial
? localizationKeys('commerce.subscriptionDetails.trialEndsOn')
: localizationKeys('commerce.subscriptionDetails.renewsAt')
}
value={formatDate(subscription.periodEnd)}
/>
Expand Down
Loading
Loading