Skip to content

chore(clerk-js,types): Update checkout flow to support free trials #6494

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
6 changes: 6 additions & 0 deletions .changeset/sour-lemons-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': minor
'@clerk/types': minor
---

Update billing resources with trial properties.
7 changes: 7 additions & 0 deletions .changeset/tender-planets-win.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 PricingTable with trial info.
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/resources/CommerceCheckout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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();
Expand All @@ -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;
}

Expand Down
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/resources/CommercePlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export class CommercePlan extends BaseResource implements CommercePlanResource {
slug!: string;
avatarUrl!: string;
features!: CommerceFeature[];
freeTrialDays!: number | null;
freeTrialEnabled!: boolean;

constructor(data: CommercePlanJSON) {
super();
Expand Down Expand Up @@ -56,6 +58,8 @@ export class CommercePlan extends BaseResource implements CommercePlanResource {
this.publiclyVisible = data.publicly_visible;
this.slug = data.slug;
this.avatarUrl = data.avatar_url;
this.freeTrialDays = this.withDefault(data.free_trial_days, null);
this.freeTrialEnabled = this.withDefault(data.free_trial_enabled, false);
this.features = (data.features || []).map(feature => new CommerceFeature(feature));
Comment on lines +61 to 63
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Snapshot parity: include free trial fields in __internal_toSnapshot.

Downstream consumers that rely on snapshots will miss these new fields. Include them (and update the corresponding snapshot type if needed).

   public __internal_toSnapshot(): CommercePlanJSONSnapshot {
     return {
       object: 'commerce_plan',
       id: this.id,
       name: this.name,
       amount: this.amount,
       amount_formatted: this.amountFormatted,
       annual_amount: this.annualAmount,
       annual_amount_formatted: this.annualAmountFormatted,
       annual_monthly_amount: this.annualMonthlyAmount,
       annual_monthly_amount_formatted: this.annualMonthlyAmountFormatted,
       currency: this.currency,
       currency_symbol: this.currencySymbol,
       description: this.description,
       is_default: this.isDefault,
       is_recurring: this.isRecurring,
       has_base_fee: this.hasBaseFee,
       for_payer_type: this.forPayerType,
       publicly_visible: this.publiclyVisible,
       slug: this.slug,
       avatar_url: this.avatarUrl,
+      free_trial_days: this.freeTrialDays,
+      free_trial_enabled: this.freeTrialEnabled,
       features: this.features.map(feature => feature.__internal_toSnapshot()),
     };
   }

To confirm types and call sites:

Also applies to: 68-91


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check if the snapshot type includes the new fields
rg -n "CommercePlanJSONSnapshot" packages | sed -n '1,200p'

# Check if code reads the new fields from snapshots
rg -n "free_trial_days|free_trial_enabled" packages

Length of output: 1029


Add free trial fields to __internal_toSnapshot for snapshot parity

We verified that the CommercePlanJSONSnapshot type already includes

free_trial_days?: number | null;
free_trial_enabled?: boolean;

so no type updates are needed. However, the two new properties must be
exported in the snapshot method:

• File: packages/clerk-js/src/core/resources/CommercePlan.ts
• Method: public __internal_toSnapshot(): CommercePlanJSONSnapshot

Apply this diff:

    slug: this.slug,
    avatar_url: this.avatarUrl,
+   free_trial_days: this.freeTrialDays,
+   free_trial_enabled: this.freeTrialEnabled,
    features: this.features.map(feature => feature.__internal_toSnapshot()),
🤖 Prompt for AI Agents
In packages/clerk-js/src/core/resources/CommercePlan.ts around lines 61 to 63,
the __internal_toSnapshot method is missing the freeTrialDays and
freeTrialEnabled properties in its returned snapshot object. Update the
__internal_toSnapshot method to include these two properties so that the
snapshot output matches the CommercePlanJSONSnapshot type and maintains parity
with the data model.


return this;
Expand Down
5 changes: 5 additions & 0 deletions packages/clerk-js/src/core/resources/CommerceSubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr
date: Date;
} | null = null;
subscriptionItems!: CommerceSubscriptionItemResource[];
eligibleForFreeTrial?: boolean;

Comment on lines +30 to 31
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Align typing and default for eligibleForFreeTrial.

Currently typed as optional and directly assigned, which can propagate undefined. Consider one of:

  • Option A (preferred): make it non-optional boolean and default to false via withDefault.
  • Option B: keep optional but still default to false to simplify consumers (then consider tightening type later).

Option A:

-  eligibleForFreeTrial?: boolean;
+  eligibleForFreeTrial!: boolean;
...
-    this.eligibleForFreeTrial = data.eligible_for_free_trial;
+    this.eligibleForFreeTrial = this.withDefault(data.eligible_for_free_trial, false);

To gauge impact, search call sites:

Also applies to: 55-56


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "eligibleForFreeTrial" packages

Length of output: 494


Align eligibleForFreeTrial typing and default to false

To ensure consumers always get a boolean (never undefined), update both the runtime resource and the TS definitions:

  • In packages/clerk-js/src/core/resources/CommerceSubscription.ts
     // before
  • eligibleForFreeTrial?: boolean;
  • eligibleForFreeTrial!: boolean;

    constructor(data: API.CommerceSubscription) {
  • this.eligibleForFreeTrial = data.eligible_for_free_trial;
  • this.eligibleForFreeTrial = this.withDefault(data.eligible_for_free_trial, false);
    }

- In `packages/types/src/commerce.ts` (around line 1264)  
```diff
 export interface CommerceSubscription {
…
-  eligibleForFreeTrial?: boolean;
+  eligibleForFreeTrial: boolean;
 …
 }

This change makes eligibleForFreeTrial a required boolean with a default of false, removing any need for downstream undefined checks.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
eligibleForFreeTrial?: boolean;
// packages/clerk-js/src/core/resources/CommerceSubscription.ts
export class CommerceSubscription extends Resource {
// …
- eligibleForFreeTrial?: boolean;
+ eligibleForFreeTrial!: boolean;
// …
constructor(data: API.CommerceSubscription) {
super();
// …
- this.eligibleForFreeTrial = data.eligible_for_free_trial;
+ this.eligibleForFreeTrial = this.withDefault(data.eligible_for_free_trial, false);
// …
}
}
Suggested change
eligibleForFreeTrial?: boolean;
// packages/types/src/commerce.ts
export interface CommerceSubscription {
// …
- eligibleForFreeTrial?: boolean;
+ eligibleForFreeTrial: boolean;
// …
}
🤖 Prompt for AI Agents
In packages/clerk-js/src/core/resources/CommerceSubscription.ts around lines 30
to 31, update the eligibleForFreeTrial property to be a required boolean instead
of optional. Set its default value to false in the runtime resource to ensure it
is never undefined. Also, update the TypeScript type definition in
packages/types/src/commerce.ts near line 1264 to reflect eligibleForFreeTrial as
a non-optional boolean. This will guarantee consumers always receive a boolean
value without needing to check for undefined.

constructor(data: CommerceSubscriptionJSON) {
super();
Expand All @@ -51,6 +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;
return this;
}
}
Expand All @@ -74,6 +76,7 @@ export class CommerceSubscriptionItem extends BaseResource implements CommerceSu
credit?: {
amount: CommerceMoney;
};
freeTrialEndsAt!: Date | null;

constructor(data: CommerceSubscriptionItemJSON) {
super();
Expand Down Expand Up @@ -103,6 +106,8 @@ export class CommerceSubscriptionItem extends BaseResource implements CommerceSu

this.amount = data.amount ? commerceMoneyFromJSON(data.amount) : undefined;
this.credit = data.credit && data.credit.amount ? { amount: commerceMoneyFromJSON(data.credit.amount) } : undefined;

this.freeTrialEndsAt = data.free_trial_ends_at ? unixEpochToDate(data.free_trial_ends_at) : null;
return this;
Comment on lines +110 to 111
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use nullish check when mapping timestamps.

Avoid truthy checks for numeric timestamps; use a nullish check to prevent edge-case misclassification.

-    this.freeTrialEndsAt = data.free_trial_ends_at ? unixEpochToDate(data.free_trial_ends_at) : null;
+    this.freeTrialEndsAt =
+      data.free_trial_ends_at != null ? unixEpochToDate(data.free_trial_ends_at) : null;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
this.freeTrialEndsAt = data.free_trial_ends_at ? unixEpochToDate(data.free_trial_ends_at) : null;
return this;
this.freeTrialEndsAt =
data.free_trial_ends_at != null ? unixEpochToDate(data.free_trial_ends_at) : null;
return this;
🤖 Prompt for AI Agents
In packages/clerk-js/src/core/resources/CommerceSubscription.ts at lines 110 to
111, replace the truthy check on data.free_trial_ends_at with a nullish check
(e.g., data.free_trial_ends_at != null) to correctly handle numeric timestamps
that could be zero or falsy but valid. This ensures the timestamp is only
considered absent if it is null or undefined, preventing misclassification of
valid zero values.

}

Expand Down
77 changes: 50 additions & 27 deletions packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand All @@ -51,6 +52,11 @@ export const CheckoutForm = withCardStateProvider(() => {
<LineItems.Title
title={plan.name}
description={planPeriod === 'annual' ? localizationKeys('commerce.billedAnnually') : undefined}
badge={
plan.freeTrialEnabled && freeTrialEndsAt ? (
<SubscriptionBadge subscription={{ status: 'free_trial' }} />
) : null
}
/>
<LineItems.Description
prefix={planPeriod === 'annual' ? 'x12' : undefined}
Expand Down Expand Up @@ -85,6 +91,20 @@ export const CheckoutForm = withCardStateProvider(() => {
<LineItems.Description text={`${totals.pastDue?.currencySymbol}${totals.pastDue?.amountFormatted}`} />
</LineItems.Group>
)}

{freeTrialEndsAt && plan.freeTrialDays && (
<LineItems.Group variant='tertiary'>
<LineItems.Title
title={localizationKeys('commerce.checkout.totalDueAfterTrial', {
days: plan.freeTrialDays,
})}
/>
<LineItems.Description
text={`${totals.grandTotal?.currencySymbol}${totals.grandTotal?.amountFormatted}`}
/>
</LineItems.Group>
)}
Comment on lines +95 to +106
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Handle missing freeTrialDays gracefully

The label always injects plan.freeTrialDays; when it’s null the UI will show “after trial ends in null days”.

Guard the value or fall back to commerce.checkout.totalDueAfterTrial without the days variable.

🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx around lines 95
to 106, the code injects plan.freeTrialDays into the label without checking if
it is null, causing the UI to display "null" days. To fix this, add a
conditional check to only pass the days variable if plan.freeTrialDays is a
valid number; otherwise, render the label without the days variable to avoid
showing "null" in the UI.


<LineItems.Group borderTop>
<LineItems.Title title={localizationKeys('commerce.totalDueToday')} />
<LineItems.Description text={`${totals.totalDueNow.currencySymbol}${totals.totalDueNow.amountFormatted}`} />
Expand Down Expand Up @@ -276,15 +296,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');
};
Comment on lines +299 to +318
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

useSubmitLabel can throw at runtime

Throwing when status === 'needs_initialization' propagates to the component tree and crashes the checkout drawer if the hook is called too early.
Return a safe placeholder instead (e.g., '...') until the state stabilises, or guard the hook’s usage.

- if (status === 'needs_initialization') {
-   throw new Error('Clerk: Invalid state');
- }
+ if (status === 'needs_initialization') {
+   return localizationKeys('formButtonPrimary'); // fallback label
+ }

Avoids unhandled exceptions in edge cases.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 useSubmitLabel = () => {
const { checkout } = useCheckout();
const { status, freeTrialEndsAt, totals } = checkout;
if (status === 'needs_initialization') {
return localizationKeys('formButtonPrimary'); // fallback label
}
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');
};
🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx around lines
299 to 318, the useSubmitLabel hook throws an error when status is
'needs_initialization', which can crash the component tree if called too early.
Instead of throwing, modify the hook to return a safe placeholder string like
'...' in this state to prevent unhandled exceptions and allow the UI to
stabilize gracefully.


const AddPaymentSourceForCheckout = withCardStateProvider(() => {
const { addPaymentSourceAndPay } = useCheckoutMutations();
const submitLabel = useSubmitLabel();
const { checkout } = useCheckout();

return (
<AddPaymentSource.Root
onSuccess={addPaymentSourceAndPay}
Expand All @@ -294,15 +331,7 @@ const AddPaymentSourceForCheckout = withCardStateProvider(() => {
<PayWithTestPaymentSource />
</DevOnly>

{totals.totalDueNow.amount > 0 ? (
<AddPaymentSource.FormButton
text={localizationKeys('commerce.pay', {
amount: `${totals.totalDueNow.currencySymbol}${totals.totalDueNow.amountFormatted}`,
})}
/>
) : (
<AddPaymentSource.FormButton text={localizationKeys('commerce.subscribe')} />
)}
<AddPaymentSource.FormButton text={submitLabel} />
</AddPaymentSource.Root>
);
});
Expand All @@ -315,8 +344,9 @@ const ExistingPaymentSourceForm = withCardStateProvider(
totalDueNow: CommerceMoney;
paymentSources: CommercePaymentSourceResource[];
}) => {
const submitLabel = useSubmitLabel();
const { checkout } = useCheckout();
const { paymentSource } = checkout;
const { paymentSource, freeTrialEndsAt } = checkout;

const { payWithExistingPaymentSource } = useCheckoutMutations();
const card = useCardState();
Expand All @@ -338,6 +368,8 @@ const ExistingPaymentSourceForm = withCardStateProvider(
});
}, [paymentSources]);

const isSchedulePayment = totalDueNow.amount > 0 && !freeTrialEndsAt;

return (
<Form
onSubmit={payWithExistingPaymentSource}
Expand All @@ -347,7 +379,7 @@ const ExistingPaymentSourceForm = withCardStateProvider(
rowGap: t.space.$4,
})}
>
{totalDueNow.amount > 0 ? (
{isSchedulePayment ? (
<Select
elementId='paymentSource'
options={options}
Expand Down Expand Up @@ -397,17 +429,8 @@ const ExistingPaymentSourceForm = withCardStateProvider(
width: '100%',
}}
isLoading={card.isLoading}
>
<Text
localizationKey={
totalDueNow.amount > 0
? localizationKeys('commerce.pay', {
amount: `${totalDueNow.currencySymbol}${totalDueNow.amountFormatted}`,
})
: localizationKeys('commerce.subscribe')
}
/>
</Button>
localizationKey={submitLabel}
/>
</Form>
);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ function Card(props: CardProps) {
} else if (planPeriod !== subscription.planPeriod && plan.annualMonthlyAmount > 0) {
shouldShowFooter = true;
shouldShowFooterNotice = false;
} else if (plan.freeTrialEnabled && subscription.freeTrialEndsAt !== null) {
shouldShowFooter = true;
shouldShowFooterNotice = true;
Comment on lines +150 to +152
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Guard against past trials: only show notice if trial end is in the future.
Current condition shows “trial ends at” even if the trial already ended. Add a time check.

Apply:

-    } else if (plan.freeTrialEnabled && subscription.freeTrialEndsAt !== null) {
+    } else if (
+      plan.freeTrialEnabled &&
+      subscription.freeTrialEndsAt !== null &&
+      subscription.freeTrialEndsAt.getTime() > Date.now()
+    ) {
       shouldShowFooter = true;
       shouldShowFooterNotice = true;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else if (plan.freeTrialEnabled && subscription.freeTrialEndsAt !== null) {
shouldShowFooter = true;
shouldShowFooterNotice = true;
} else if (
plan.freeTrialEnabled &&
subscription.freeTrialEndsAt !== null &&
subscription.freeTrialEndsAt.getTime() > Date.now()
) {
shouldShowFooter = true;
shouldShowFooterNotice = true;
🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx
around lines 150 to 152, the condition for showing the trial end notice does not
check if the trial end date is in the future. Update the condition to also
verify that subscription.freeTrialEndsAt is greater than the current date/time,
so the notice only appears if the trial has not yet ended.

} else {
shouldShowFooter = false;
shouldShowFooterNotice = false;
Expand Down Expand Up @@ -232,9 +235,13 @@ function Card(props: CardProps) {
<Text
elementDescriptor={descriptors.pricingTableCardFooterNotice}
variant={isCompact ? 'buttonSmall' : 'buttonLarge'}
localizationKey={localizationKeys('badge__startsAt', {
date: subscription?.periodStartDate,
})}
localizationKey={
plan.freeTrialEnabled && subscription.freeTrialEndsAt !== null
? localizationKeys('badge__trialEndsAt', {
date: subscription?.freeTrialEndsAt,
})
: localizationKeys('badge__startsAt', { date: subscription?.periodStartDate })
}
colorScheme='secondary'
sx={t => ({
paddingBlock: t.space.$1x5,
Expand Down
6 changes: 4 additions & 2 deletions packages/clerk-js/src/ui/components/Subscriptions/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T extends { status: CommerceSubscriptionItemResource['status'] }>({
subscription,
elementDescriptor,
}: {
subscription: CommerceSubscriptionItemResource;
subscription: T | { status: 'free_trial' };
elementDescriptor?: ElementDescriptor;
Comment on lines +20 to 25
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Inconsistent typing for status – consider extending the core enum instead of local work-arounds

The component now unions { status: 'free_trial' } to work around the fact that CommerceSubscriptionStatus doesn’t include "free_trial".
This leaks an ad-hoc type downstream and forces the @ts-expect-error casts later on.

Prefer updating CommerceSubscriptionStatus (and upstream API) to include "free_trial" and keep the prop simply subscription: { status: CommerceSubscriptionStatus }.

This restores compile-time safety and removes the need for the union / ignores.

🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/Subscriptions/badge.tsx around lines 20
to 25, the subscription prop type uses a union to add 'free_trial' status
separately, causing inconsistent typing and requiring @ts-expect-error casts. To
fix this, update the CommerceSubscriptionStatus enum in the core types and
upstream API to include 'free_trial' as a valid status, then change the
subscription prop type to use only { status: CommerceSubscriptionStatus }
without unions. This will ensure consistent typing and remove the need for type
ignores.

}) => {
return (
Expand Down
17 changes: 13 additions & 4 deletions packages/clerk-js/src/ui/contexts/components/Plans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export const usePlansContext = () => {
return false;
}, [clerk, subscriberType]);

const { subscriptionItems, revalidate: revalidateSubscriptions } = useSubscription();
const { subscriptionItems, revalidate: revalidateSubscriptions, data: topLevelSubscription } = useSubscription();

// Invalidates cache but does not fetch immediately
const { data: plans, revalidate: revalidatePlans } = usePlans({ mode: 'cache' });
Expand Down Expand Up @@ -187,6 +187,7 @@ export const usePlansContext = () => {
const buttonPropsForPlan = useCallback(
({
plan,
// TODO(@COMMERCE): This needs to be removed.
subscription: sub,
isCompact = false,
selectedPlanPeriod = 'annual',
Expand All @@ -211,6 +212,13 @@ export const usePlansContext = () => {

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

const freeTrialOr = (localizationKey: LocalizationKey): LocalizationKey => {
if (plan?.freeTrialEnabled && topLevelSubscription?.eligibleForFreeTrial) {
return localizationKeys('commerce.startFreeTrial__days', { days: plan.freeTrialDays ?? 0 });
}
return localizationKey;
};

Comment on lines +215 to +221
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Guard against zero / null trial days

freeTrialOr interpolates plan.freeTrialDays ?? 0.
If the backend returns 0 or null, the UI will generate “Start 0-day free trial”, which looks broken.

- return localizationKeys('commerce.startFreeTrial__days', { days: plan.freeTrialDays ?? 0 });
+const days = plan.freeTrialDays ?? 0;
+return days > 0
+  ? localizationKeys('commerce.startFreeTrial__days', { days })
+  : localizationKeys('commerce.startFreeTrial');

This keeps the copy natural when the duration is unknown or disabled.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const freeTrialOr = (localizationKey: LocalizationKey): LocalizationKey => {
if (plan?.freeTrialEnabled && topLevelSubscription?.eligibleForFreeTrial) {
return localizationKeys('commerce.startFreeTrial__days', { days: plan.freeTrialDays ?? 0 });
}
return localizationKey;
};
const freeTrialOr = (localizationKey: LocalizationKey): LocalizationKey => {
if (plan?.freeTrialEnabled && topLevelSubscription?.eligibleForFreeTrial) {
const days = plan.freeTrialDays ?? 0;
return days > 0
? localizationKeys('commerce.startFreeTrial__days', { days })
: localizationKeys('commerce.startFreeTrial');
}
return localizationKey;
};
🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/contexts/components/Plans.tsx around lines 215 to
221, the function freeTrialOr uses plan.freeTrialDays ?? 0, which can produce
"Start 0-day free trial" if freeTrialDays is 0 or null. Update the logic to
check if freeTrialDays is a positive number before interpolating it; if not,
return the original localizationKey to avoid displaying unnatural "0-day" trial
text.

const getLocalizationKey = () => {
// Handle subscription cases
if (subscription) {
Expand Down Expand Up @@ -246,20 +254,21 @@ export const usePlansContext = () => {
// Handle non-subscription cases
const hasNonDefaultSubscriptions =
subscriptionItems.filter(subscription => !subscription.plan.isDefault).length > 0;

return hasNonDefaultSubscriptions
? localizationKeys('commerce.switchPlan')
: localizationKeys('commerce.subscribe');
: freeTrialOr(localizationKeys('commerce.subscribe'));
};

return {
localizationKey: getLocalizationKey(),
localizationKey: freeTrialOr(getLocalizationKey()),
variant: isCompact ? 'bordered' : 'solid',
colorScheme: isCompact ? 'secondary' : 'primary',
isDisabled: !canManageBilling,
disabled: !canManageBilling,
};
},
[activeOrUpcomingSubscriptionWithPlanPeriod, canManageBilling, subscriptionItems],
[activeOrUpcomingSubscriptionWithPlanPeriod, canManageBilling, subscriptionItems, topLevelSubscription],
);

const captionForSubscription = useCallback((subscription: CommerceSubscriptionItemResource) => {
Expand Down
4 changes: 3 additions & 1 deletion packages/clerk-js/src/ui/elements/LineItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,10 @@ interface TitleProps {
title?: string | LocalizationKey;
description?: string | LocalizationKey;
icon?: React.ComponentType;
badge?: React.ReactNode;
}
Comment on lines +87 to 88
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Badge slot theming/localization contract; add JSDoc (and consider a theming wrapper).

Good addition. To align with our UI guidelines:

  • Document that callers must pass a localized component and that theming should be handled by that component (or expose a theming slot).
  • Optionally wrap the badge with a dedicated element descriptor to enable stable theming of the slot.

Apply JSDoc on the prop (Lines 87-88):

 interface TitleProps {
   title?: string | LocalizationKey;
   description?: string | LocalizationKey;
   icon?: React.ComponentType;
-  badge?: React.ReactNode;
+  /**
+   * Optional badge element rendered next to the title.
+   * Caller is responsible for localization and theming of the badge contents (e.g. pass <SubscriptionBadge />).
+   */
+  badge?: React.ReactNode;
 }

Optional theming slot wrapper (Lines 124-125) — requires adding a corresponding descriptor (e.g. lineItemsTitleBadge) to descriptors before use:

-          {badge}
+          {/* wrap to expose a stable theming hook */}
+          {badge ? (
+            <Span elementDescriptor={descriptors.lineItemsTitleBadge}>
+              {badge}
+            </Span>
+          ) : null}

To ensure consistency, verify the SubscriptionBadge component uses elementDescriptor and localized strings:

Also applies to: 90-90, 124-125


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify the badge implementation uses elementDescriptor and localization
fd badge.tsx packages | xargs -I {} sh -c 'echo "----- {}"; sed -n "1,200p" "{}" | rg -n "elementDescriptor|localizationKey|localizationKeys" -n || true'

Length of output: 1468


Document badge prop and expose a theming slot

The SubscriptionBadge component already accepts an elementDescriptor and uses localizationKeys, so adding a JSDoc and a wrapper here will align with our UI guidelines.

• At Line 87–88 (TitleProps), add:

 interface TitleProps {
   title?: string | LocalizationKey;
   description?: string | LocalizationKey;
   icon?: React.ComponentType;
-  badge?: React.ReactNode;
+  /**
+   * Optional badge element rendered next to the title.
+   * The caller is responsible for providing a localized and themed badge
+   * (e.g. `<SubscriptionBadge elementDescriptor={descriptors.lineItemsTitleBadge} />`).
+   */
+  badge?: React.ReactNode;
 }

• At the render site (around Line 124–125), wrap badge in a stable theming slot:

-          {badge}
+          {badge ? (
+            <Span elementDescriptor={descriptors.lineItemsTitleBadge}>
+              {badge}
+            </Span>
+          ) : null}

This ensures consumers can target lineItemsTitleBadge in our design system for consistent theming.

🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/elements/LineItems.tsx around lines 87 to 88, add a
JSDoc comment to document the `badge` prop in the `TitleProps` interface
explaining its purpose and usage. Then, around lines 124 to 125 where `badge` is
rendered, wrap it inside a stable theming slot component named
`lineItemsTitleBadge` to expose a theming slot for consistent styling according
to UI guidelines.


const Title = React.forwardRef<HTMLTableCellElement, TitleProps>(({ title, description, icon }, ref) => {
const Title = React.forwardRef<HTMLTableCellElement, TitleProps>(({ title, description, icon, badge = null }, ref) => {
const context = React.useContext(GroupContext);
if (!context) {
throw new Error('LineItems.Title must be used within LineItems.Group');
Expand Down Expand Up @@ -120,6 +121,7 @@ const Title = React.forwardRef<HTMLTableCellElement, TitleProps>(({ title, descr
/>
) : null}
<Span localizationKey={title} />
{badge}
</Span>
) : null}
{description ? (
Expand Down
Loading
Loading