diff --git a/.changeset/rotten-lines-cough.md b/.changeset/rotten-lines-cough.md
new file mode 100644
index 00000000000..1b6d7f47140
--- /dev/null
+++ b/.changeset/rotten-lines-cough.md
@@ -0,0 +1,9 @@
+---
+'@clerk/localizations': minor
+'@clerk/clerk-js': minor
+'@clerk/shared': minor
+'@clerk/types': minor
+---
+
+Add support for trials in ``
+ - Added `freeTrialEndsAt` property to `CommerceCheckoutResource` interface.
diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json
index da405fd6a95..00856735535 100644
--- a/packages/clerk-js/bundlewatch.config.json
+++ b/packages/clerk-js/bundlewatch.config.json
@@ -1,6 +1,6 @@
{
"files": [
- { "path": "./dist/clerk.js", "maxSize": "622KB" },
+ { "path": "./dist/clerk.js", "maxSize": "622.25KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "76KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "117KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "58KB" },
@@ -23,7 +23,7 @@
{ "path": "./dist/waitlist*.js", "maxSize": "1.5KB" },
{ "path": "./dist/keylessPrompt*.js", "maxSize": "6.5KB" },
{ "path": "./dist/pricingTable*.js", "maxSize": "4.02KB" },
- { "path": "./dist/checkout*.js", "maxSize": "8.5KB" },
+ { "path": "./dist/checkout*.js", "maxSize": "8.75KB" },
{ "path": "./dist/up-billing-page*.js", "maxSize": "3.0KB" },
{ "path": "./dist/op-billing-page*.js", "maxSize": "3.0KB" },
{ "path": "./dist/up-plans-page*.js", "maxSize": "1.0KB" },
diff --git a/packages/clerk-js/jest.setup.ts b/packages/clerk-js/jest.setup.ts
index 1cc099eab25..9a2c3aa202d 100644
--- a/packages/clerk-js/jest.setup.ts
+++ b/packages/clerk-js/jest.setup.ts
@@ -33,7 +33,7 @@ if (typeof window !== 'undefined') {
})),
});
- //@ts-expect-error
+ //@ts-expect-error - JSDOM doesn't provide IntersectionObserver, so we mock it for testing
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
@@ -53,4 +53,18 @@ if (typeof window !== 'undefined') {
return null;
}
};
+
+ // Mock HTMLCanvasElement.prototype.getContext to prevent errors
+ HTMLCanvasElement.prototype.getContext = jest.fn().mockImplementation(((contextType: string) => {
+ if (contextType === '2d') {
+ return {
+ fillRect: jest.fn(),
+ getImageData: jest.fn(() => ({ data: new Uint8ClampedArray([255, 255, 255, 255]) }) as unknown as ImageData),
+ } as unknown as CanvasRenderingContext2D;
+ }
+ if (contextType === 'webgl' || contextType === 'webgl2') {
+ return {} as unknown as WebGLRenderingContext;
+ }
+ return null;
+ }) as any) as jest.MockedFunction;
}
diff --git a/packages/clerk-js/src/core/resources/CommerceCheckout.ts b/packages/clerk-js/src/core/resources/CommerceCheckout.ts
index 04fb7e0465c..f8da65639a8 100644
--- a/packages/clerk-js/src/core/resources/CommerceCheckout.ts
+++ b/packages/clerk-js/src/core/resources/CommerceCheckout.ts
@@ -7,6 +7,8 @@ import type {
ConfirmCheckoutParams,
} from '@clerk/types';
+import { unixEpochToDate } from '@/utils/date';
+
import { commerceTotalsFromJSON } from '../../utils';
import { BaseResource, CommercePaymentSource, CommercePlan, isClerkAPIResponseError } from './internal';
@@ -21,6 +23,7 @@ export class CommerceCheckout extends BaseResource implements CommerceCheckoutRe
status!: 'needs_confirmation' | 'completed';
totals!: CommerceCheckoutTotals;
isImmediatePlanChange!: boolean;
+ freeTrialEndsAt!: Date | null;
constructor(data: CommerceCheckoutJSON, orgId?: string) {
super();
@@ -43,6 +46,7 @@ export class CommerceCheckout extends BaseResource implements CommerceCheckoutRe
this.status = data.status;
this.totals = commerceTotalsFromJSON(data.totals);
this.isImmediatePlanChange = data.is_immediate_plan_change;
+ this.freeTrialEndsAt = data.free_trial_ends_at ? unixEpochToDate(data.free_trial_ends_at) : null;
return this;
}
diff --git a/packages/clerk-js/src/core/resources/CommerceSubscription.ts b/packages/clerk-js/src/core/resources/CommerceSubscription.ts
index 5c776c0e1d2..5677cff4f01 100644
--- a/packages/clerk-js/src/core/resources/CommerceSubscription.ts
+++ b/packages/clerk-js/src/core/resources/CommerceSubscription.ts
@@ -52,7 +52,7 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr
}
: null;
this.subscriptionItems = (data.subscription_items || []).map(item => new CommerceSubscriptionItem(item));
- this.eligibleForFreeTrial = data.eligible_for_free_trial;
+ this.eligibleForFreeTrial = this.withDefault(data.eligible_for_free_trial, false);
return this;
}
}
diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx
index 6d5bd306a10..e83895bffd6 100644
--- a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx
+++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx
@@ -19,7 +19,7 @@ export const CheckoutComplete = () => {
const { setIsOpen } = useDrawerContext();
const { newSubscriptionRedirectUrl } = useCheckoutContext();
const { checkout } = useCheckout();
- const { totals, paymentSource, planPeriodStart } = checkout;
+ const { totals, paymentSource, planPeriodStart, freeTrialEndsAt } = checkout;
const [mousePosition, setMousePosition] = useState({ x: 256, y: 256 });
const [currentPosition, setCurrentPosition] = useState({ x: 256, y: 256 });
@@ -310,9 +310,11 @@ export const CheckoutComplete = () => {
as='h2'
textVariant='h2'
localizationKey={
- totals.totalDueNow.amount > 0
- ? localizationKeys('commerce.checkout.title__paymentSuccessful')
- : localizationKeys('commerce.checkout.title__subscriptionSuccessful')
+ freeTrialEndsAt
+ ? localizationKeys('commerce.checkout.title__trialSuccess')
+ : totals.totalDueNow.amount > 0
+ ? localizationKeys('commerce.checkout.title__paymentSuccessful')
+ : localizationKeys('commerce.checkout.title__subscriptionSuccessful')
}
sx={t => ({
opacity: 0,
@@ -399,17 +401,24 @@ export const CheckoutComplete = () => {
+
+ {freeTrialEndsAt ? (
+
+
+
+
+ ) : null}
0
+ totals.totalDueNow.amount > 0 || freeTrialEndsAt !== null
? localizationKeys('commerce.checkout.lineItems.title__paymentMethod')
: localizationKeys('commerce.checkout.lineItems.title__subscriptionBegins')
}
/>
0
+ totals.totalDueNow.amount > 0 || freeTrialEndsAt !== null
? paymentSource
? paymentSource.paymentMethod !== 'card'
? `${capitalize(paymentSource.paymentMethod)}`
diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx
index 20e8e9f86cb..0f442f237a3 100644
--- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx
+++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx
@@ -17,6 +17,7 @@ import { Box, Button, Col, descriptors, Flex, Form, localizationKeys, Text } fro
import { ChevronUpDown, InformationCircle } from '../../icons';
import * as AddPaymentSource from '../PaymentSources/AddPaymentSource';
import { PaymentSourceRow } from '../PaymentSources/PaymentSourceRow';
+import { SubscriptionBadge } from '../Subscriptions/badge';
type PaymentMethodSource = 'existing' | 'new';
@@ -25,7 +26,7 @@ const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1);
export const CheckoutForm = withCardStateProvider(() => {
const { checkout } = useCheckout();
- const { id, plan, totals, isImmediatePlanChange, planPeriod } = checkout;
+ const { id, plan, totals, isImmediatePlanChange, planPeriod, freeTrialEndsAt } = checkout;
if (!id) {
return null;
@@ -53,6 +54,11 @@ export const CheckoutForm = withCardStateProvider(() => {
+ ) : null
+ }
/>
{
)}
+
+ {!!freeTrialEndsAt && !!plan.freeTrialDays && (
+
+
+
+
+ )}
+
@@ -278,15 +298,32 @@ export const PayWithTestPaymentSource = () => {
);
};
-const AddPaymentSourceForCheckout = withCardStateProvider(() => {
- const { addPaymentSourceAndPay } = useCheckoutMutations();
+const useSubmitLabel = () => {
const { checkout } = useCheckout();
- const { status, totals } = checkout;
+ const { status, freeTrialEndsAt, totals } = checkout;
if (status === 'needs_initialization') {
- return null;
+ throw new Error('Clerk: Invalid state');
+ }
+
+ if (freeTrialEndsAt) {
+ return localizationKeys('commerce.startFreeTrial');
}
+ if (totals.totalDueNow.amount > 0) {
+ return localizationKeys('commerce.pay', {
+ amount: `${totals.totalDueNow.currencySymbol}${totals.totalDueNow.amountFormatted}`,
+ });
+ }
+
+ return localizationKeys('commerce.subscribe');
+};
+
+const AddPaymentSourceForCheckout = withCardStateProvider(() => {
+ const { addPaymentSourceAndPay } = useCheckoutMutations();
+ const submitLabel = useSubmitLabel();
+ const { checkout } = useCheckout();
+
return (
{
- {totals.totalDueNow.amount > 0 ? (
-
- ) : (
-
- )}
+
);
});
@@ -317,8 +346,9 @@ const ExistingPaymentSourceForm = withCardStateProvider(
totalDueNow: CommerceMoneyAmount;
paymentSources: CommercePaymentSourceResource[];
}) => {
+ const submitLabel = useSubmitLabel();
const { checkout } = useCheckout();
- const { paymentSource } = checkout;
+ const { paymentSource, freeTrialEndsAt } = checkout;
const { payWithExistingPaymentSource } = useCheckoutMutations();
const card = useCardState();
@@ -340,6 +370,8 @@ const ExistingPaymentSourceForm = withCardStateProvider(
});
}, [paymentSources]);
+ const isSchedulePayment = totalDueNow.amount > 0 && !freeTrialEndsAt;
+
return (
);
},
diff --git a/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx b/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx
new file mode 100644
index 00000000000..2b757aa8786
--- /dev/null
+++ b/packages/clerk-js/src/ui/components/Checkout/__tests__/Checkout.test.tsx
@@ -0,0 +1,468 @@
+import { Drawer } from '@/ui/elements/Drawer';
+
+import { render, waitFor } from '../../../../testUtils';
+import { bindCreateFixtures } from '../../../utils/test/createFixtures';
+import { Checkout } from '..';
+
+const { createFixtures } = bindCreateFixtures('Checkout');
+
+// Mock Payment Element to be ready so the submit button is rendered during tests
+jest.mock('@clerk/shared/react', () => {
+ const actual = jest.requireActual('@clerk/shared/react');
+ return {
+ ...actual,
+ __experimental_PaymentElementProvider: ({ children }: { children: any }) => children,
+ __experimental_PaymentElement: (_: { fallback?: any }) => null,
+ __experimental_usePaymentElement: () => ({
+ submit: jest.fn().mockResolvedValue({ data: { gateway: 'stripe', paymentToken: 'tok_test' }, error: null }),
+ reset: jest.fn().mockResolvedValue(undefined),
+ isFormReady: true,
+ provider: { name: 'stripe' },
+ isProviderReady: true,
+ }),
+ };
+});
+
+describe('Checkout', () => {
+ it('displays spinner when checkout is initializing', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ // Mock billing to prevent actual API calls and stay in loading state
+ fixtures.clerk.billing.startCheckout.mockImplementation(() => new Promise(() => {}));
+
+ const { baseElement } = render(
+ {}}
+ >
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ // Verify the checkout drawer renders
+ expect(baseElement.querySelector('[role="dialog"]')).toBeVisible();
+
+ // Verify the checkout title is displayed
+ const title = baseElement.querySelector('[data-localization-key="commerce.checkout.title"]');
+ expect(title).toBeVisible();
+ expect(title).toHaveTextContent('Checkout');
+
+ // Verify spinner is shown during initialization
+ const spinner = baseElement.querySelector('span[aria-live="polite"]');
+ expect(spinner).toBeVisible();
+ });
+ });
+
+ it('renders drawer structure and localization correctly', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ // Mock billing to prevent actual API calls
+ fixtures.clerk.billing.startCheckout.mockImplementation(() => new Promise(() => {}));
+
+ const { baseElement, getByRole } = render(
+ {}}
+ >
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ // Verify localized title is displayed
+ expect(getByRole('heading', { name: 'Checkout' })).toBeVisible();
+
+ // Verify drawer structure
+ expect(baseElement.querySelector('[role="dialog"]')).toBeVisible();
+ expect(baseElement.querySelector('.cl-checkout-root')).toBeVisible();
+
+ // Verify close button is present
+ const closeButton = baseElement.querySelector('[aria-label="Close drawer"]');
+ expect(closeButton).toBeVisible();
+ });
+ });
+
+ it('handles checkout initialization errors gracefully', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ // Mock billing to reject with a Clerk-like error shape
+ fixtures.clerk.billing.startCheckout.mockRejectedValue({
+ status: 400,
+ errors: [{ code: 'unknown_error' }],
+ });
+
+ const { baseElement } = render(
+ {}}
+ >
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ // Component should still render the drawer structure even with errors
+ expect(baseElement.querySelector('[role="dialog"]')).toBeVisible();
+ expect(baseElement.querySelector('[data-localization-key="commerce.checkout.title"]')).toBeVisible();
+ });
+ });
+
+ it('displays proper loading state during checkout initialization', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ // Mock billing to stay in loading state
+ fixtures.clerk.billing.startCheckout.mockImplementation(() => new Promise(() => {}));
+
+ const { baseElement } = render(
+ {}}
+ >
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ // Should show loading state while checkout initializes
+ const spinner = baseElement.querySelector('span[aria-live="polite"]');
+ expect(spinner).toBeVisible();
+ expect(spinner).toHaveAttribute('aria-busy', 'true');
+
+ // Should not show any checkout content yet
+ expect(baseElement.querySelector('form')).toBeNull();
+ });
+ });
+
+ it('maintains accessibility attributes correctly', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ fixtures.clerk.billing.startCheckout.mockImplementation(() => new Promise(() => {}));
+
+ const { baseElement } = render(
+ {}}
+ >
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ // Check dialog accessibility
+ const dialog = baseElement.querySelector('[role="dialog"]');
+ expect(dialog).toBeVisible();
+ expect(dialog).toHaveAttribute('tabindex', '-1');
+
+ // Check heading hierarchy
+ const heading = baseElement.querySelector('h2[data-localization-key="commerce.checkout.title"]');
+ expect(heading).toBeVisible();
+
+ // Check focus guards for modal
+ const focusGuards = baseElement.querySelectorAll('[data-floating-ui-focus-guard]');
+ expect(focusGuards.length).toBeGreaterThan(0);
+
+ // Check spinner accessibility
+ const spinner = baseElement.querySelector('[aria-live="polite"]');
+ expect(spinner).toHaveAttribute('aria-busy', 'true');
+ });
+ });
+
+ it('renders without crashing when all required props are provided', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ // Mock billing to prevent actual API calls
+ fixtures.clerk.billing.startCheckout.mockImplementation(() => new Promise(() => {}));
+
+ const { baseElement } = render(
+ {}}
+ >
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ // Basic smoke test - component renders without throwing
+ expect(baseElement.querySelector('[role="dialog"]')).toBeVisible();
+ });
+ });
+
+ it('renders without errors for monthly period', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+ fixtures.clerk.billing.startCheckout.mockImplementation(() => new Promise(() => {}));
+ const { baseElement } = render(
+ {}}
+ >
+
+ ,
+ { wrapper },
+ );
+ await waitFor(() => {
+ expect(baseElement.querySelector('[role="dialog"]')).toBeVisible();
+ });
+ });
+
+ it('renders without errors for annual period', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+ fixtures.clerk.billing.startCheckout.mockImplementation(() => new Promise(() => {}));
+ const { baseElement } = render(
+ {}}
+ >
+
+ ,
+ { wrapper },
+ );
+ await waitFor(() => {
+ expect(baseElement.querySelector('[role="dialog"]')).toBeVisible();
+ });
+ });
+
+ it('renders with correct CSS classes and structure', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ fixtures.clerk.billing.startCheckout.mockImplementation(() => new Promise(() => {}));
+
+ const { baseElement } = render(
+ {}}
+ >
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ // Verify CSS classes are applied
+ expect(baseElement.querySelector('.cl-checkout-root')).toBeVisible();
+ expect(baseElement.querySelector('.cl-drawerRoot')).toBeVisible();
+ expect(baseElement.querySelector('.cl-drawerContent')).toBeVisible();
+ expect(baseElement.querySelector('.cl-drawerHeader')).toBeVisible();
+ expect(baseElement.querySelector('.cl-drawerTitle')).toBeVisible();
+ // Close button present
+ const closeButton = baseElement.querySelector('[aria-label="Close drawer"]');
+ expect(closeButton).toBeVisible();
+ });
+ });
+
+ it('renders free trial details during confirmation stage', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ const freeTrialEndsAt = new Date('2025-08-19');
+
+ fixtures.clerk.billing.startCheckout.mockResolvedValue({
+ id: 'chk_trial_1',
+ status: 'needs_confirmation',
+ externalClientSecret: 'cs_test_trial',
+ externalGatewayId: 'gw_test',
+ totals: {
+ subtotal: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' },
+ grandTotal: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' },
+ taxTotal: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' },
+ credit: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' },
+ pastDue: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' },
+ totalDueNow: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' },
+ },
+ isImmediatePlanChange: true,
+ planPeriod: 'month',
+ plan: {
+ id: 'plan_trial',
+ name: 'Pro',
+ description: 'Pro plan',
+ features: [],
+ fee: {
+ amount: 1000,
+ amountFormatted: '10.00',
+ currency: 'USD',
+ currencySymbol: '$',
+ },
+ annualFee: {
+ amount: 12000,
+ amountFormatted: '120.00',
+ currency: 'USD',
+ currencySymbol: '$',
+ },
+ annualMonthlyFee: {
+ amount: 1000,
+ amountFormatted: '10.00',
+ currency: 'USD',
+ currencySymbol: '$',
+ },
+ slug: 'pro',
+ avatarUrl: '',
+ publiclyVisible: true,
+ isDefault: true,
+ isRecurring: true,
+ hasBaseFee: false,
+ forPayerType: 'user',
+ freeTrialDays: 14,
+ freeTrialEnabled: true,
+ },
+ paymentSource: undefined,
+ confirm: jest.fn(),
+ freeTrialEndsAt,
+ } as any);
+
+ const { getByRole, getByText } = render(
+ {}}
+ >
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ expect(getByRole('heading', { name: 'Checkout' })).toBeVisible();
+
+ expect(getByText('Free trial')).toBeVisible();
+
+ expect(getByText('Total Due after trial ends in 14 days')).toBeVisible();
+ expect(getByRole('button', { name: 'Start free trial' })).toBeVisible();
+ });
+ });
+
+ it('renders trial success details in completed stage', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ const freeTrialEndsAt = new Date('2025-08-19');
+
+ fixtures.clerk.billing.startCheckout.mockResolvedValue({
+ id: 'chk_trial_2',
+ status: 'completed',
+ externalClientSecret: 'cs_test_trial_2',
+ externalGatewayId: 'gw_test',
+ totals: {
+ subtotal: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' },
+ grandTotal: { amount: 1000, amountFormatted: '10.00', currency: 'USD', currencySymbol: '$' },
+ taxTotal: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' },
+ credit: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' },
+ pastDue: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' },
+ totalDueNow: { amount: 0, amountFormatted: '0.00', currency: 'USD', currencySymbol: '$' },
+ },
+ isImmediatePlanChange: true,
+ planPeriod: 'month',
+ plan: {
+ id: 'plan_trial',
+ name: 'Pro',
+ description: 'Pro plan',
+ features: [],
+ fee: {
+ amount: 1000,
+ amountFormatted: '10.00',
+ currency: 'USD',
+ currencySymbol: '$',
+ },
+ annualFee: {
+ amount: 12000,
+ amountFormatted: '120.00',
+ currency: 'USD',
+ currencySymbol: '$',
+ },
+ annualMonthlyFee: {
+ amount: 1000,
+ amountFormatted: '10.00',
+ currency: 'USD',
+ currencySymbol: '$',
+ },
+ slug: 'pro',
+ avatarUrl: '',
+ publiclyVisible: true,
+ isDefault: true,
+ isRecurring: true,
+ hasBaseFee: false,
+ forPayerType: 'user',
+ freeTrialDays: 7,
+ freeTrialEnabled: true,
+ },
+ paymentSource: undefined,
+ confirm: jest.fn(),
+ freeTrialEndsAt,
+ } as any);
+
+ const { getByText } = render(
+ {}}
+ >
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ // Title indicates trial success
+ expect(getByText('Trial successfully started!')).toBeVisible();
+ expect(getByText('Trial ends on')).toBeVisible();
+ expect(getByText('August 19, 2025')).toBeVisible();
+ });
+ });
+});
diff --git a/packages/clerk-js/src/ui/components/Subscriptions/badge.tsx b/packages/clerk-js/src/ui/components/Subscriptions/badge.tsx
index 64524cc7f7a..eecba6b9abd 100644
--- a/packages/clerk-js/src/ui/components/Subscriptions/badge.tsx
+++ b/packages/clerk-js/src/ui/components/Subscriptions/badge.tsx
@@ -7,19 +7,21 @@ const keys = {
active: 'badge__activePlan',
upcoming: 'badge__upcomingPlan',
past_due: 'badge__pastDuePlan',
+ free_trial: 'badge__freeTrial',
};
const colors = {
active: 'secondary',
upcoming: 'primary',
past_due: 'warning',
+ free_trial: 'secondary',
};
-export const SubscriptionBadge = ({
+export const SubscriptionBadge = ({
subscription,
elementDescriptor,
}: {
- subscription: CommerceSubscriptionItemResource;
+ subscription: T | { status: 'free_trial' };
elementDescriptor?: ElementDescriptor;
}) => {
return (
diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx
index 6f36256bad9..b123ff22e9d 100644
--- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx
+++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx
@@ -226,7 +226,7 @@ export const usePlansContext = () => {
const isEligibleForTrial = topLevelSubscription?.eligibleForFreeTrial;
if (isSignedOut || isEligibleForTrial) {
- return localizationKeys('commerce.startFreeTrial', { days: plan.freeTrialDays ?? 0 });
+ return localizationKeys('commerce.startFreeTrial__days', { days: plan.freeTrialDays ?? 0 });
}
}
return localizationKey;
diff --git a/packages/clerk-js/src/ui/elements/LineItems.tsx b/packages/clerk-js/src/ui/elements/LineItems.tsx
index f7cbe03eddb..119d93e0a52 100644
--- a/packages/clerk-js/src/ui/elements/LineItems.tsx
+++ b/packages/clerk-js/src/ui/elements/LineItems.tsx
@@ -84,9 +84,10 @@ interface TitleProps {
title?: string | LocalizationKey;
description?: string | LocalizationKey;
icon?: React.ComponentType;
+ badge?: React.ReactNode;
}
-const Title = React.forwardRef(({ title, description, icon }, ref) => {
+const Title = React.forwardRef(({ title, description, icon, badge = null }, ref) => {
const context = React.useContext(GroupContext);
if (!context) {
throw new Error('LineItems.Title must be used within LineItems.Group');
@@ -120,6 +121,7 @@ const Title = React.forwardRef(({ title, descr
/>
) : null}
+ {badge}
) : null}
{description ? (
diff --git a/packages/clerk-js/src/ui/utils/test/createFixtures.tsx b/packages/clerk-js/src/ui/utils/test/createFixtures.tsx
index 2b82dbaea6e..bcafd820363 100644
--- a/packages/clerk-js/src/ui/utils/test/createFixtures.tsx
+++ b/packages/clerk-js/src/ui/utils/test/createFixtures.tsx
@@ -90,7 +90,13 @@ const unboundCreateFixtures = (
const MockClerkProvider = (props: any) => {
const { children } = props;
- const componentsWithoutContext = ['UsernameSection', 'UserProfileSection', 'SubscriptionDetails', 'PlanDetails'];
+ const componentsWithoutContext = [
+ 'UsernameSection',
+ 'UserProfileSection',
+ 'SubscriptionDetails',
+ 'PlanDetails',
+ 'Checkout',
+ ];
const contextWrappedChildren = !componentsWithoutContext.includes(componentName) ? (
+ * ```
+ */
+ freeTrialEndsAt: Date | null;
}
diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts
index 7344a8b0d02..a666690907e 100644
--- a/packages/types/src/json.ts
+++ b/packages/types/src/json.ts
@@ -884,6 +884,8 @@ export interface CommerceCheckoutJSON extends ClerkResourceJSON {
status: 'needs_confirmation' | 'completed';
totals: CommerceCheckoutTotalsJSON;
is_immediate_plan_change: boolean;
+ // TODO(@COMMERCE): Remove optional after GA.
+ free_trial_ends_at?: number | null;
}
export interface ApiKeyJSON extends ClerkResourceJSON {
diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts
index 580f02a427f..d03a3426f42 100644
--- a/packages/types/src/localization.ts
+++ b/packages/types/src/localization.ts
@@ -143,6 +143,7 @@ export type __internal_LocalizationResource = {
badge__unverified: LocalizationValue;
badge__requiresAction: LocalizationValue;
badge__you: LocalizationValue;
+ badge__freeTrial: LocalizationValue;
badge__currentPlan: LocalizationValue;
badge__upcomingPlan: LocalizationValue;
badge__activePlan: LocalizationValue;
@@ -175,7 +176,8 @@ export type __internal_LocalizationResource = {
keepSubscription: LocalizationValue;
reSubscribe: LocalizationValue;
subscribe: LocalizationValue;
- startFreeTrial: LocalizationValue<'days'>;
+ startFreeTrial: LocalizationValue;
+ startFreeTrial__days: LocalizationValue<'days'>;
switchPlan: LocalizationValue;
switchToMonthly: LocalizationValue;
switchToAnnual: LocalizationValue;
@@ -240,10 +242,12 @@ export type __internal_LocalizationResource = {
title: LocalizationValue;
title__paymentSuccessful: LocalizationValue;
title__subscriptionSuccessful: LocalizationValue;
+ title__trialSuccess: LocalizationValue;
description__paymentSuccessful: LocalizationValue;
description__subscriptionSuccessful: LocalizationValue;
lineItems: {
title__totalPaid: LocalizationValue;
+ title__freeTrialEndsAt: LocalizationValue;
title__paymentMethod: LocalizationValue;
title__statementId: LocalizationValue;
title__subscriptionBegins: LocalizationValue;
@@ -254,6 +258,7 @@ export type __internal_LocalizationResource = {
};
downgradeNotice: LocalizationValue;
pastDueNotice: LocalizationValue;
+ totalDueAfterTrial: LocalizationValue<'days'>;
perMonth: LocalizationValue;
};
};