From d6877f76145cf4f8c0c99ca3cf7377f8d26aedab Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:59:40 -0600 Subject: [PATCH 1/8] feat(clerk-js,localization,shared,ui): Show seat-based prices --- packages/clerk-js/sandbox/scenarios/index.ts | 1 + .../sandbox/scenarios/pricing-table-sbb.ts | 337 ++++++++++++++++++ .../src/core/modules/billing/namespace.ts | 1 + packages/localizations/src/en-US.ts | 2 + packages/shared/src/types/localization.ts | 2 + .../components/PricingTable/PricingTable.tsx | 1 + .../PricingTable/PricingTableDefault.tsx | 135 ++++++- 7 files changed, 473 insertions(+), 6 deletions(-) create mode 100644 packages/clerk-js/sandbox/scenarios/pricing-table-sbb.ts diff --git a/packages/clerk-js/sandbox/scenarios/index.ts b/packages/clerk-js/sandbox/scenarios/index.ts index 73ddfca0ce6..f906e8b43ad 100644 --- a/packages/clerk-js/sandbox/scenarios/index.ts +++ b/packages/clerk-js/sandbox/scenarios/index.ts @@ -1,2 +1,3 @@ export { UserButtonSignedIn } from './user-button-signed-in'; export { CheckoutAccountCredit } from './checkout-account-credit'; +export { PricingTableSBB } from './pricing-table-sbb'; diff --git a/packages/clerk-js/sandbox/scenarios/pricing-table-sbb.ts b/packages/clerk-js/sandbox/scenarios/pricing-table-sbb.ts new file mode 100644 index 00000000000..f17d9d33195 --- /dev/null +++ b/packages/clerk-js/sandbox/scenarios/pricing-table-sbb.ts @@ -0,0 +1,337 @@ +import { + clerkHandlers, + http, + HttpResponse, + EnvironmentService, + SessionService, + setClerkState, + type MockScenario, + UserService, +} from '@clerk/msw'; +import type { BillingPlanJSON } from '@clerk/shared/types'; + +export function PricingTableSBB(): MockScenario { + const user = UserService.create(); + const session = SessionService.create(user); + const money = (amount: number) => ({ + amount, + amount_formatted: (amount / 100).toFixed(2), + currency: 'USD', + currency_symbol: '$', + }); + const mockFeatures = [ + { + object: 'feature' as const, + id: 'feature_custom_domains', + name: 'Custom domains', + description: 'Connect and manage branded domains.', + slug: 'custom-domains', + avatar_url: null, + }, + { + object: 'feature' as const, + id: 'feature_saml_sso', + name: 'SAML SSO', + description: 'Single sign-on with enterprise identity providers.', + slug: 'saml-sso', + avatar_url: null, + }, + { + object: 'feature' as const, + id: 'feature_audit_logs', + name: 'Audit logs', + description: 'Track account activity and security events.', + slug: 'audit-logs', + avatar_url: null, + }, + { + object: 'feature' as const, + id: 'feature_priority_support', + name: 'Priority support', + description: 'Faster response times from the support team.', + slug: 'priority-support', + avatar_url: null, + }, + { + object: 'feature' as const, + id: 'feature_rate_limit_boost', + name: 'Rate limit boost', + description: 'Higher API request thresholds for production traffic.', + slug: 'rate-limit-boost', + avatar_url: null, + }, + ]; + + setClerkState({ + environment: EnvironmentService.MULTI_SESSION, + session, + user, + }); + + const subscriptionHandler = http.get('https://*.clerk.accounts.dev/v1/me/billing/subscription', () => { + return HttpResponse.json({ + response: { + data: {}, + }, + }); + }); + + const paymentMethodsHandler = http.get('https://*.clerk.accounts.dev/v1/me/billing/payment_methods', () => { + return HttpResponse.json({ + response: { + data: {}, + }, + }); + }); + + const plansHandler = http.get('https://*.clerk.accounts.dev/v1/billing/plans', () => { + return HttpResponse.json({ + data: [ + { + object: 'commerce_plan', + id: 'plan_a_sbb', + name: 'Plan A', + fee: money(12989), + annual_fee: null, + annual_monthly_fee: null, + description: null, + is_default: false, + is_recurring: true, + has_base_fee: true, + for_payer_type: 'org', + publicly_visible: true, + slug: 'plan-a-sbb', + avatar_url: null, + features: mockFeatures, + free_trial_enabled: false, + free_trial_days: null, + unit_prices: [ + { + name: 'seat', + block_size: 1, + tiers: [ + { + id: 'tier_plan_a_seats_1', + object: 'commerce_unit_price', + starts_at_block: 1, + ends_after_block: 5, + fee_per_block: money(0), + }, + ], + }, + ], + }, + { + object: 'commerce_plan', + id: 'plan_b_sbb', + name: 'Plan B', + fee: money(12989), + annual_fee: null, + annual_monthly_fee: null, + description: null, + is_default: false, + is_recurring: true, + has_base_fee: true, + for_payer_type: 'org', + publicly_visible: true, + slug: 'plan-b-sbb', + avatar_url: null, + features: mockFeatures, + free_trial_enabled: false, + free_trial_days: null, + unit_prices: [ + { + name: 'seat', + block_size: 1, + tiers: [ + { + id: 'tier_plan_b_seats_1', + object: 'commerce_unit_price', + starts_at_block: 1, + ends_after_block: null, + fee_per_block: money(1200), + }, + ], + }, + ], + }, + { + object: 'commerce_plan', + id: 'plan_c_sbb', + name: 'Plan C', + fee: money(0), + annual_fee: null, + annual_monthly_fee: null, + description: null, + is_default: false, + is_recurring: true, + has_base_fee: false, + for_payer_type: 'org', + publicly_visible: true, + slug: 'plan-c-sbb', + avatar_url: null, + features: mockFeatures, + free_trial_enabled: false, + free_trial_days: null, + unit_prices: [ + { + name: 'seat', + block_size: 1, + tiers: [ + { + id: 'tier_plan_c_seats_1', + object: 'commerce_unit_price', + starts_at_block: 1, + ends_after_block: null, + fee_per_block: money(1200), + }, + ], + }, + ], + }, + { + object: 'commerce_plan', + id: 'plan_d_sbb', + name: 'Plan D', + fee: money(12989), + annual_fee: null, + annual_monthly_fee: null, + description: null, + is_default: false, + is_recurring: true, + has_base_fee: true, + for_payer_type: 'org', + publicly_visible: true, + slug: 'plan-d-sbb', + avatar_url: null, + features: mockFeatures, + free_trial_enabled: false, + free_trial_days: null, + unit_prices: [ + { + name: 'seat', + block_size: 1, + tiers: [ + { + id: 'tier_plan_d_seats_1', + object: 'commerce_unit_price', + starts_at_block: 1, + ends_after_block: 5, + fee_per_block: money(0), + }, + { + id: 'tier_plan_d_seats_2', + object: 'commerce_unit_price', + starts_at_block: 6, + ends_after_block: null, + fee_per_block: money(1200), + }, + ], + }, + ], + }, + { + object: 'commerce_plan', + id: 'plan_e_sbb', + name: 'Plan E', + fee: money(12989), + annual_fee: null, + annual_monthly_fee: null, + description: null, + is_default: false, + is_recurring: true, + has_base_fee: true, + for_payer_type: 'org', + publicly_visible: true, + slug: 'plan-e-sbb', + avatar_url: null, + features: mockFeatures, + free_trial_enabled: false, + free_trial_days: null, + }, + { + object: 'commerce_plan', + id: 'plan_f_sbb', + name: 'Plan F', + fee: money(0), + annual_fee: null, + annual_monthly_fee: null, + description: null, + is_default: true, + is_recurring: true, + has_base_fee: false, + for_payer_type: 'org', + publicly_visible: true, + slug: 'plan-f-sbb', + avatar_url: null, + features: mockFeatures, + free_trial_enabled: false, + free_trial_days: null, + unit_prices: [ + { + name: 'seat', + block_size: 1, + tiers: [ + { + id: 'tier_plan_f_seats_1', + object: 'commerce_unit_price', + starts_at_block: 1, + ends_after_block: 5, + fee_per_block: money(0), + }, + { + id: 'tier_plan_f_seats_2', + object: 'commerce_unit_price', + starts_at_block: 6, + ends_after_block: null, + fee_per_block: money(1200), + }, + ], + }, + ], + }, + { + object: 'commerce_plan', + id: 'plan_g_sbb', + name: 'Plan G', + fee: money(0), + annual_fee: null, + annual_monthly_fee: null, + description: null, + is_default: false, + is_recurring: true, + has_base_fee: false, + for_payer_type: 'org', + publicly_visible: true, + slug: 'plan-g-sbb', + avatar_url: null, + features: mockFeatures, + free_trial_enabled: false, + free_trial_days: null, + unit_prices: [ + { + name: 'seat', + block_size: 1, + tiers: [ + { + id: 'tier_plan_g_seats_1', + object: 'commerce_unit_price', + starts_at_block: 1, + ends_after_block: null, + fee_per_block: money(0), + }, + ], + }, + ], + }, + ] as BillingPlanJSON[], + }); + }); + + return { + description: 'PricingTable with seat-based billing plans', + handlers: [plansHandler, subscriptionHandler, paymentMethodsHandler, ...clerkHandlers], + initialState: { session, user }, + name: 'pricing-table-sbb', + }; +} diff --git a/packages/clerk-js/src/core/modules/billing/namespace.ts b/packages/clerk-js/src/core/modules/billing/namespace.ts index f055a531d5c..96ef77fec61 100644 --- a/packages/clerk-js/src/core/modules/billing/namespace.ts +++ b/packages/clerk-js/src/core/modules/billing/namespace.ts @@ -43,6 +43,7 @@ export class Billing implements BillingNamespace { method: 'GET', search: convertPageToOffsetSearchParams(searchParams), }).then(res => { + console.log('getPlans', { res }); const { data: plans, total_count } = res as unknown as ClerkPaginatedResponse; return { diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index d2134717645..04d9b4a8400 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -119,6 +119,7 @@ export const enUS: LocalizationResource = { manage: 'Manage', manageSubscription: 'Manage subscription', month: 'Month', + monthPerUnit: 'Month per {{unitName}}', monthly: 'Monthly', pastDue: 'Past due', pay: 'Pay {{amount}}', @@ -173,6 +174,7 @@ export const enUS: LocalizationResource = { viewFeatures: 'View features', viewPayment: 'View payment', year: 'Year', + yearPerUnit: 'Year per {{unitName}}', }, createOrganization: { formButtonSubmit: 'Create organization', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index d81be772c21..faca9cfcd75 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -175,7 +175,9 @@ export type __internal_LocalizationResource = { membershipRole__guestMember: LocalizationValue; billing: { month: LocalizationValue; + monthPerUnit: LocalizationValue<'unitName'>; year: LocalizationValue; + yearPerUnit: LocalizationValue<'unitName'>; free: LocalizationValue; getStarted: LocalizationValue; manage: LocalizationValue; diff --git a/packages/ui/src/components/PricingTable/PricingTable.tsx b/packages/ui/src/components/PricingTable/PricingTable.tsx index 9ced0c884ff..6306d856691 100644 --- a/packages/ui/src/components/PricingTable/PricingTable.tsx +++ b/packages/ui/src/components/PricingTable/PricingTable.tsx @@ -24,6 +24,7 @@ const PricingTableRoot = (props: PricingTableProps) => { : [] : plans; }, [clerk.isSignedIn, plans, subscription]); + console.log('plansToRender', { plansToRender, plans, subscription }); const defaultPlanPeriod = useMemo(() => { if (isCompact) { diff --git a/packages/ui/src/components/PricingTable/PricingTableDefault.tsx b/packages/ui/src/components/PricingTable/PricingTableDefault.tsx index 1e512ad33a9..a7611ec13a5 100644 --- a/packages/ui/src/components/PricingTable/PricingTableDefault.tsx +++ b/packages/ui/src/components/PricingTable/PricingTableDefault.tsx @@ -1,5 +1,10 @@ import { __internal_useOrganizationBase, useClerk, useSession } from '@clerk/shared/react'; -import type { BillingPlanResource, BillingSubscriptionPlanPeriod, PricingTableProps } from '@clerk/shared/types'; +import type { + BillingPlanResource, + BillingSubscriptionPlanPeriod, + PricingTableProps, + BillingPlanUnitPrice, +} from '@clerk/shared/types'; import * as React from 'react'; import { Switch } from '@/ui/elements/Switch'; @@ -21,7 +26,7 @@ import { Span, Text, } from '../../customizables'; -import { Check, Plus } from '../../icons'; +import { Check, Plus, Users } from '../../icons'; import { common, InternalThemeProvider } from '../../styledSystem'; import { SubscriptionBadge } from '../Subscriptions/badge'; import { getPricingFooterState } from './utils/pricing-footer-state'; @@ -99,6 +104,7 @@ interface CardProps { } function Card(props: CardProps) { + console.log('Card', props); const { plan, planPeriod, setPlanPeriod, onSelect, props: pricingTableProps, isCompact = false } = props; const clerk = useClerk(); const { isSignedIn } = useSession(); @@ -296,9 +302,32 @@ const CardHeader = React.forwardRef((props, ref : plan.fee; }, [planSupportsAnnual, planPeriod, plan.fee, plan.annualMonthlyFee]); + const singleUnitPriceTierFee = React.useMemo(() => { + if (plan.hasBaseFee || !plan.unitPrices || plan.unitPrices.length !== 1) { + return null; + } + + const [unitPrice] = plan.unitPrices; + if (unitPrice.tiers.length !== 1) { + return null; + } + + return unitPrice.tiers[0].feePerBlock; + }, [plan.hasBaseFee, plan.unitPrices]); + + const displayedFee = singleUnitPriceTierFee ?? fee; + const feeFormatted = React.useMemo(() => { - return normalizeFormatted(fee.amountFormatted); - }, [fee.amountFormatted]); + return normalizeFormatted(displayedFee.amountFormatted); + }, [displayedFee.amountFormatted]); + + const feePeriodText = React.useMemo(() => { + if (!plan.hasBaseFee && plan.unitPrices) { + return localizationKeys('billing.monthPerUnit', { unitName: plan.unitPrices[0].name }); + } + + return localizationKeys('billing.month'); + }, [plan.unitPrices]); return ( ((props, ref variant={isCompact ? 'h2' : 'h1'} colorScheme='body' > - {fee.currencySymbol} + {displayedFee.currencySymbol} {feeFormatted} {!plan.isDefault ? ( @@ -376,7 +405,7 @@ const CardHeader = React.forwardRef((props, ref marginInlineEnd: t.space.$0x25, }, })} - localizationKey={localizationKeys('billing.month')} + localizationKey={feePeriodText} /> ) : null} @@ -454,6 +483,9 @@ const CardFeaturesList = React.forwardRef padding: 0, })} > + {plan.unitPrices && (plan.hasBaseFee || plan.unitPrices[0].tiers.length > 1) ? ( + + ) : null} {plan.features.slice(0, hasMoreFeatures ? (isCompact ? 3 : 8) : totalFeatures).map(feature => ( ); }); + +const CardFeaturesListSeatCost = ({ plan }: { plan: BillingPlanResource }) => { + const unitPrices = plan.unitPrices; + + const seatPriceText = React.useMemo(() => { + if (!unitPrices) { + return null; + } + + const seatUnitPrice = unitPrices.find(unitPrice => unitPrice.name.toLowerCase() === 'seats') ?? unitPrices[0]; + + if (!seatUnitPrice) { + return null; + } + + const formatTierFee = (tier: BillingPlanUnitPrice['tiers'][number]) => + `${tier.feePerBlock.currencySymbol}${normalizeFormatted(tier.feePerBlock.amountFormatted)}`; + + if (seatUnitPrice.tiers.length === 1) { + const tier = seatUnitPrice.tiers[0]; + + if (tier.feePerBlock.amount === 0 && tier.endsAfterBlock !== null) { + const prefix = plan.fee.amount === 0 ? 'Free up to' : 'Up to'; + return `${prefix} ${tier.endsAfterBlock} seats`; + } + + if (tier.feePerBlock.amount !== 0) { + return `${formatTierFee(tier)}/mo per seat`; + } + + return null; + } + + if (seatUnitPrice.tiers.length === 2) { + const [includedTier, additionalTier] = seatUnitPrice.tiers; + + if ( + includedTier && + additionalTier && + includedTier.feePerBlock.amount === 0 && + includedTier.endsAfterBlock !== null && + additionalTier.feePerBlock.amount !== 0 + ) { + const prefix = plan.fee.amount === 0 ? 'Free up to' : 'Up to'; + return `${prefix} ${includedTier.endsAfterBlock} seats (${formatTierFee(additionalTier)}/mo for additional)`; + } + } + + return null; + }, [plan.fee.amount, unitPrices]); + + if (!seatPriceText) { + return null; + } + + return ( + ({ + display: 'flex', + alignItems: 'baseline', + gap: t.space.$2, + margin: 0, + padding: 0, + })} + > + ({ + transform: `translateY(${t.space.$0x25})`, + })} + /> + + ({ + fontWeight: t.fontWeights.$normal, + })} + > + {seatPriceText} + + + + ); +}; From ee73fa441bc9385ca64518269c3cb9fbaf35e1fc Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:40:05 -0600 Subject: [PATCH 2/8] feat(): localize seat cost --- packages/localizations/src/en-US.ts | 13 +++ packages/shared/src/types/localization.ts | 13 +++ .../PricingTable/PricingTableDefault.tsx | 84 +++++++++++++++++-- 3 files changed, 101 insertions(+), 9 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 04d9b4a8400..cdfc31c5924 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -119,6 +119,7 @@ export const enUS: LocalizationResource = { manage: 'Manage', manageSubscription: 'Manage subscription', month: 'Month', + monthAbbreviation: 'mo', monthPerUnit: 'Month per {{unitName}}', monthly: 'Monthly', pastDue: 'Past due', @@ -142,6 +143,17 @@ export const enUS: LocalizationResource = { pricingTable: { billingCycle: 'Billing cycle', included: 'Included', + seatCost: { + freeUpToSeats: 'Free up to {{endsAfterBlock}} seats', + upToSeats: 'Up to {{endsAfterBlock}} seats', + perSeat: '{{feePerBlockAmount}}/{{periodAbbreviation}} per seat', + additionalSeats: '({{additionalTierFeePerBlockAmount}}/{{periodAbbreviation}} for additional)', + tooltip: { + freeForUpToSeats: 'Free for up to {{endsAfterBlock}} seats.', + additionalSeatsEach: 'Additional seats are {{feePerBlockAmount}}/{{period}} each.', + firstSeatsIncludedInPlan: 'First {{endsAfterBlock}} seats are included in the plan.', + }, + }, }, reSubscribe: 'Resubscribe', seeAllFeatures: 'See all features', @@ -174,6 +186,7 @@ export const enUS: LocalizationResource = { viewFeatures: 'View features', viewPayment: 'View payment', year: 'Year', + yearAbbreviation: 'yr', yearPerUnit: 'Year per {{unitName}}', }, createOrganization: { diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index faca9cfcd75..5957583ff36 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -175,8 +175,10 @@ export type __internal_LocalizationResource = { membershipRole__guestMember: LocalizationValue; billing: { month: LocalizationValue; + monthAbbreviation: LocalizationValue; monthPerUnit: LocalizationValue<'unitName'>; year: LocalizationValue; + yearAbbreviation: LocalizationValue; yearPerUnit: LocalizationValue<'unitName'>; free: LocalizationValue; getStarted: LocalizationValue; @@ -257,6 +259,17 @@ export type __internal_LocalizationResource = { pricingTable: { billingCycle: LocalizationValue; included: LocalizationValue; + seatCost: { + freeUpToSeats: LocalizationValue<'endsAfterBlock'>; + upToSeats: LocalizationValue<'endsAfterBlock'>; + perSeat: LocalizationValue<'feePerBlockAmount' | 'periodAbbreviation'>; + additionalSeats: LocalizationValue<'additionalTierFeePerBlockAmount' | 'periodAbbreviation'>; + tooltip: { + freeForUpToSeats: LocalizationValue<'endsAfterBlock'>; + additionalSeatsEach: LocalizationValue<'feePerBlockAmount' | 'period'>; + firstSeatsIncludedInPlan: LocalizationValue<'endsAfterBlock'>; + }; + }; }; checkout: { title: LocalizationValue; diff --git a/packages/ui/src/components/PricingTable/PricingTableDefault.tsx b/packages/ui/src/components/PricingTable/PricingTableDefault.tsx index a7611ec13a5..5931eac3fb0 100644 --- a/packages/ui/src/components/PricingTable/PricingTableDefault.tsx +++ b/packages/ui/src/components/PricingTable/PricingTableDefault.tsx @@ -25,6 +25,7 @@ import { SimpleButton, Span, Text, + useLocalizations, } from '../../customizables'; import { Check, Plus, Users } from '../../icons'; import { common, InternalThemeProvider } from '../../styledSystem'; @@ -547,9 +548,12 @@ const CardFeaturesList = React.forwardRef }); const CardFeaturesListSeatCost = ({ plan }: { plan: BillingPlanResource }) => { + const { t } = useLocalizations(); const unitPrices = plan.unitPrices; + const period = t(localizationKeys('billing.month')); + const periodAbbreviation = t(localizationKeys('billing.monthAbbreviation')); - const seatPriceText = React.useMemo(() => { + const seatPriceContent = React.useMemo(() => { if (!unitPrices) { return null; } @@ -567,12 +571,25 @@ const CardFeaturesListSeatCost = ({ plan }: { plan: BillingPlanResource }) => { const tier = seatUnitPrice.tiers[0]; if (tier.feePerBlock.amount === 0 && tier.endsAfterBlock !== null) { - const prefix = plan.fee.amount === 0 ? 'Free up to' : 'Up to'; - return `${prefix} ${tier.endsAfterBlock} seats`; + return { + baseText: localizationKeys( + plan.fee.amount === 0 + ? 'billing.pricingTable.seatCost.freeUpToSeats' + : 'billing.pricingTable.seatCost.upToSeats', + { + endsAfterBlock: tier.endsAfterBlock, + }, + ), + }; } if (tier.feePerBlock.amount !== 0) { - return `${formatTierFee(tier)}/mo per seat`; + return { + baseText: localizationKeys('billing.pricingTable.seatCost.perSeat', { + feePerBlockAmount: formatTierFee(tier), + periodAbbreviation, + }), + }; } return null; @@ -588,15 +605,46 @@ const CardFeaturesListSeatCost = ({ plan }: { plan: BillingPlanResource }) => { includedTier.endsAfterBlock !== null && additionalTier.feePerBlock.amount !== 0 ) { - const prefix = plan.fee.amount === 0 ? 'Free up to' : 'Up to'; - return `${prefix} ${includedTier.endsAfterBlock} seats (${formatTierFee(additionalTier)}/mo for additional)`; + const additionalTierFeePerBlockAmount = formatTierFee(additionalTier); + const tooltipPrefixText = t( + localizationKeys( + plan.fee.amount === 0 + ? 'billing.pricingTable.seatCost.tooltip.freeForUpToSeats' + : 'billing.pricingTable.seatCost.tooltip.firstSeatsIncludedInPlan', + { + endsAfterBlock: includedTier.endsAfterBlock, + }, + ), + ); + const tooltipAdditionalText = t( + localizationKeys('billing.pricingTable.seatCost.tooltip.additionalSeatsEach', { + feePerBlockAmount: additionalTierFeePerBlockAmount, + period, + }), + ); + + return { + baseText: localizationKeys( + plan.fee.amount === 0 + ? 'billing.pricingTable.seatCost.freeUpToSeats' + : 'billing.pricingTable.seatCost.upToSeats', + { + endsAfterBlock: includedTier.endsAfterBlock, + }, + ), + additionalText: localizationKeys('billing.pricingTable.seatCost.additionalSeats', { + additionalTierFeePerBlockAmount, + periodAbbreviation, + }), + additionalTooltipText: `${tooltipPrefixText} ${tooltipAdditionalText}`, + }; } } return null; - }, [plan.fee.amount, unitPrices]); + }, [period, periodAbbreviation, plan.fee.amount, t, unitPrices]); - if (!seatPriceText) { + if (!seatPriceContent) { return null; } @@ -630,7 +678,25 @@ const CardFeaturesListSeatCost = ({ plan }: { plan: BillingPlanResource }) => { fontWeight: t.fontWeights.$normal, })} > - {seatPriceText} + + {seatPriceContent.additionalText ? ( + <> + {' '} + {seatPriceContent.additionalTooltipText ? ( + + + + + + + ) : ( + + )} + + ) : null} From e37b02386970ffbfc7cccd774fb3829dfd4f9bc6 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:37:16 -0600 Subject: [PATCH 3/8] fix(): remove console.log --- packages/clerk-js/src/core/modules/billing/namespace.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/clerk-js/src/core/modules/billing/namespace.ts b/packages/clerk-js/src/core/modules/billing/namespace.ts index 96ef77fec61..f055a531d5c 100644 --- a/packages/clerk-js/src/core/modules/billing/namespace.ts +++ b/packages/clerk-js/src/core/modules/billing/namespace.ts @@ -43,7 +43,6 @@ export class Billing implements BillingNamespace { method: 'GET', search: convertPageToOffsetSearchParams(searchParams), }).then(res => { - console.log('getPlans', { res }); const { data: plans, total_count } = res as unknown as ClerkPaginatedResponse; return { From 50761fd62bd257dc4dc1eaf782c6f5c8107dfbc0 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:44:55 -0600 Subject: [PATCH 4/8] fix(): rm console.log --- packages/ui/src/components/PricingTable/PricingTableDefault.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ui/src/components/PricingTable/PricingTableDefault.tsx b/packages/ui/src/components/PricingTable/PricingTableDefault.tsx index 5931eac3fb0..eb4369fcd3c 100644 --- a/packages/ui/src/components/PricingTable/PricingTableDefault.tsx +++ b/packages/ui/src/components/PricingTable/PricingTableDefault.tsx @@ -105,7 +105,6 @@ interface CardProps { } function Card(props: CardProps) { - console.log('Card', props); const { plan, planPeriod, setPlanPeriod, onSelect, props: pricingTableProps, isCompact = false } = props; const clerk = useClerk(); const { isSignedIn } = useSession(); From d5f9deae145e8589b63d60561b4e1d87c91eb253 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:30:57 -0600 Subject: [PATCH 5/8] fix(): Render unlimited seats --- .../sandbox/scenarios/pricing-table-sbb.ts | 34 +++++++++++++++++++ packages/localizations/src/en-US.ts | 1 + packages/shared/src/types/localization.ts | 1 + .../PricingTable/PricingTableDefault.tsx | 6 ++++ 4 files changed, 42 insertions(+) diff --git a/packages/clerk-js/sandbox/scenarios/pricing-table-sbb.ts b/packages/clerk-js/sandbox/scenarios/pricing-table-sbb.ts index f17d9d33195..295b1b3a983 100644 --- a/packages/clerk-js/sandbox/scenarios/pricing-table-sbb.ts +++ b/packages/clerk-js/sandbox/scenarios/pricing-table-sbb.ts @@ -324,6 +324,40 @@ export function PricingTableSBB(): MockScenario { }, ], }, + { + object: 'commerce_plan', + id: 'plan_h_sbb', + name: 'Plan H', + fee: money(12989), + annual_fee: money(10000), + annual_monthly_fee: money(833), + description: null, + is_default: false, + is_recurring: true, + has_base_fee: true, + for_payer_type: 'org', + publicly_visible: true, + slug: 'plan-h-sbb', + avatar_url: null, + features: mockFeatures, + free_trial_enabled: false, + free_trial_days: null, + unit_prices: [ + { + name: 'seat', + block_size: 1, + tiers: [ + { + id: 'tier_plan_h_seats_1', + object: 'commerce_unit_price', + starts_at_block: 1, + ends_after_block: null, + fee_per_block: money(0), + }, + ], + }, + ], + }, ] as BillingPlanJSON[], }); }); diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index cdfc31c5924..35575f4249a 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -148,6 +148,7 @@ export const enUS: LocalizationResource = { upToSeats: 'Up to {{endsAfterBlock}} seats', perSeat: '{{feePerBlockAmount}}/{{periodAbbreviation}} per seat', additionalSeats: '({{additionalTierFeePerBlockAmount}}/{{periodAbbreviation}} for additional)', + unlimitedSeats: 'Unlimited seats', tooltip: { freeForUpToSeats: 'Free for up to {{endsAfterBlock}} seats.', additionalSeatsEach: 'Additional seats are {{feePerBlockAmount}}/{{period}} each.', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 5957583ff36..3cd375f9268 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -264,6 +264,7 @@ export type __internal_LocalizationResource = { upToSeats: LocalizationValue<'endsAfterBlock'>; perSeat: LocalizationValue<'feePerBlockAmount' | 'periodAbbreviation'>; additionalSeats: LocalizationValue<'additionalTierFeePerBlockAmount' | 'periodAbbreviation'>; + unlimitedSeats: LocalizationValue; tooltip: { freeForUpToSeats: LocalizationValue<'endsAfterBlock'>; additionalSeatsEach: LocalizationValue<'feePerBlockAmount' | 'period'>; diff --git a/packages/ui/src/components/PricingTable/PricingTableDefault.tsx b/packages/ui/src/components/PricingTable/PricingTableDefault.tsx index eb4369fcd3c..5bb742da749 100644 --- a/packages/ui/src/components/PricingTable/PricingTableDefault.tsx +++ b/packages/ui/src/components/PricingTable/PricingTableDefault.tsx @@ -591,6 +591,12 @@ const CardFeaturesListSeatCost = ({ plan }: { plan: BillingPlanResource }) => { }; } + if (tier.feePerBlock.amount === 0 && tier.endsAfterBlock === null) { + return { + baseText: localizationKeys('billing.pricingTable.seatCost.unlimitedSeats'), + }; + } + return null; } From 9cb295c0d8f2e7f5ed800b40ac23e2a4b82bf2af Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:13:25 -0600 Subject: [PATCH 6/8] Refactor seats info in pricing table --- packages/localizations/src/en-US.ts | 1 + packages/shared/src/types/localization.ts | 1 + .../PricingTable/PricingTableDefault.tsx | 185 ++++++------ .../__tests__/PricingTable.test.tsx | 270 ++++++++++++++++++ 4 files changed, 369 insertions(+), 88 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 35575f4249a..96a61994cb0 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -147,6 +147,7 @@ export const enUS: LocalizationResource = { freeUpToSeats: 'Free up to {{endsAfterBlock}} seats', upToSeats: 'Up to {{endsAfterBlock}} seats', perSeat: '{{feePerBlockAmount}}/{{periodAbbreviation}} per seat', + includedSeats: '{{includedSeats}} seats included', additionalSeats: '({{additionalTierFeePerBlockAmount}}/{{periodAbbreviation}} for additional)', unlimitedSeats: 'Unlimited seats', tooltip: { diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 3cd375f9268..96ef73b2f63 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -263,6 +263,7 @@ export type __internal_LocalizationResource = { freeUpToSeats: LocalizationValue<'endsAfterBlock'>; upToSeats: LocalizationValue<'endsAfterBlock'>; perSeat: LocalizationValue<'feePerBlockAmount' | 'periodAbbreviation'>; + includedSeats: LocalizationValue<'includedSeats'>; additionalSeats: LocalizationValue<'additionalTierFeePerBlockAmount' | 'periodAbbreviation'>; unlimitedSeats: LocalizationValue; tooltip: { diff --git a/packages/ui/src/components/PricingTable/PricingTableDefault.tsx b/packages/ui/src/components/PricingTable/PricingTableDefault.tsx index 5bb742da749..34c5e08c22f 100644 --- a/packages/ui/src/components/PricingTable/PricingTableDefault.tsx +++ b/packages/ui/src/components/PricingTable/PricingTableDefault.tsx @@ -27,7 +27,7 @@ import { Text, useLocalizations, } from '../../customizables'; -import { Check, Plus, Users } from '../../icons'; +import { Check, Plus, User, Users } from '../../icons'; import { common, InternalThemeProvider } from '../../styledSystem'; import { SubscriptionBadge } from '../Subscriptions/badge'; import { getPricingFooterState } from './utils/pricing-footer-state'; @@ -552,7 +552,7 @@ const CardFeaturesListSeatCost = ({ plan }: { plan: BillingPlanResource }) => { const period = t(localizationKeys('billing.month')); const periodAbbreviation = t(localizationKeys('billing.monthAbbreviation')); - const seatPriceContent = React.useMemo(() => { + const seatRows = React.useMemo(() => { if (!unitPrices) { return null; } @@ -565,39 +565,39 @@ const CardFeaturesListSeatCost = ({ plan }: { plan: BillingPlanResource }) => { const formatTierFee = (tier: BillingPlanUnitPrice['tiers'][number]) => `${tier.feePerBlock.currencySymbol}${normalizeFormatted(tier.feePerBlock.amountFormatted)}`; + const getCapacityText = (endsAfterBlock: number | null) => + endsAfterBlock === null + ? localizationKeys('billing.pricingTable.seatCost.unlimitedSeats') + : localizationKeys('billing.pricingTable.seatCost.upToSeats', { endsAfterBlock }); if (seatUnitPrice.tiers.length === 1) { const tier = seatUnitPrice.tiers[0]; - - if (tier.feePerBlock.amount === 0 && tier.endsAfterBlock !== null) { - return { - baseText: localizationKeys( - plan.fee.amount === 0 - ? 'billing.pricingTable.seatCost.freeUpToSeats' - : 'billing.pricingTable.seatCost.upToSeats', - { - endsAfterBlock: tier.endsAfterBlock, - }, - ), - }; - } + const rows: Array<{ + elementId: string; + icon: typeof User | typeof Users; + text: ReturnType; + additionalText?: ReturnType; + additionalTooltipText?: string; + }> = []; if (tier.feePerBlock.amount !== 0) { - return { - baseText: localizationKeys('billing.pricingTable.seatCost.perSeat', { + rows.push({ + elementId: 'seats', + icon: User, + text: localizationKeys('billing.pricingTable.seatCost.perSeat', { feePerBlockAmount: formatTierFee(tier), periodAbbreviation, }), - }; + }); } - if (tier.feePerBlock.amount === 0 && tier.endsAfterBlock === null) { - return { - baseText: localizationKeys('billing.pricingTable.seatCost.unlimitedSeats'), - }; - } + rows.push({ + elementId: rows.length ? 'seats-limit' : 'seats', + icon: Users, + text: getCapacityText(tier.endsAfterBlock), + }); - return null; + return rows; } if (seatUnitPrice.tiers.length === 2) { @@ -628,82 +628,91 @@ const CardFeaturesListSeatCost = ({ plan }: { plan: BillingPlanResource }) => { }), ); - return { - baseText: localizationKeys( - plan.fee.amount === 0 - ? 'billing.pricingTable.seatCost.freeUpToSeats' - : 'billing.pricingTable.seatCost.upToSeats', - { - endsAfterBlock: includedTier.endsAfterBlock, - }, - ), - additionalText: localizationKeys('billing.pricingTable.seatCost.additionalSeats', { - additionalTierFeePerBlockAmount, - periodAbbreviation, - }), - additionalTooltipText: `${tooltipPrefixText} ${tooltipAdditionalText}`, - }; + return [ + { + elementId: 'seats', + icon: User, + text: localizationKeys('billing.pricingTable.seatCost.includedSeats', { + includedSeats: includedTier.endsAfterBlock, + }), + additionalText: localizationKeys('billing.pricingTable.seatCost.additionalSeats', { + additionalTierFeePerBlockAmount, + periodAbbreviation, + }), + additionalTooltipText: `${tooltipPrefixText} ${tooltipAdditionalText}`, + }, + { + elementId: 'seats-limit', + icon: Users, + text: getCapacityText(additionalTier.endsAfterBlock), + }, + ]; } } return null; }, [period, periodAbbreviation, plan.fee.amount, t, unitPrices]); - if (!seatPriceContent) { + if (!seatRows?.length) { return null; } return ( - ({ - display: 'flex', - alignItems: 'baseline', - gap: t.space.$2, - margin: 0, - padding: 0, - })} - > - ({ - transform: `translateY(${t.space.$0x25})`, - })} - /> - - + {seatRows.map(row => ( + ({ - fontWeight: t.fontWeights.$normal, + display: 'flex', + alignItems: 'baseline', + gap: t.space.$2, + margin: 0, + padding: 0, })} > - - {seatPriceContent.additionalText ? ( - <> - {' '} - {seatPriceContent.additionalTooltipText ? ( - - - - - - - ) : ( - - )} - - ) : null} - - - + ({ + transform: `translateY(${t.space.$0x25})`, + })} + /> + + ({ + fontWeight: t.fontWeights.$normal, + })} + > + + {row.additionalText ? ( + <> + {' '} + {row.additionalTooltipText ? ( + + + + + + + ) : ( + + )} + + ) : null} + + + + ))} + ); }; diff --git a/packages/ui/src/components/PricingTable/__tests__/PricingTable.test.tsx b/packages/ui/src/components/PricingTable/__tests__/PricingTable.test.tsx index 36ac5f13728..280223bf028 100644 --- a/packages/ui/src/components/PricingTable/__tests__/PricingTable.test.tsx +++ b/packages/ui/src/components/PricingTable/__tests__/PricingTable.test.tsx @@ -637,3 +637,273 @@ describe('PricingTable - plans visibility', () => { }); }); }); + +describe('PricingTable - seat tiers rendering', () => { + const createSeatPlan = ({ + id, + feeAmount, + tiers, + }: { + id: string; + feeAmount: number; + tiers: Array<{ + id: string; + startsAtBlock: number; + endsAfterBlock: number | null; + feePerBlock: { + amount: number; + amountFormatted: string; + currencySymbol: string; + currency: string; + }; + }>; + }) => { + return { + id, + name: 'Seat Plan', + fee: { + amount: feeAmount, + amountFormatted: feeAmount === 0 ? '0.00' : '20.00', + currencySymbol: '$', + currency: 'USD', + }, + annualFee: { + amount: feeAmount === 0 ? 0 : 20000, + amountFormatted: feeAmount === 0 ? '0.00' : '200.00', + currencySymbol: '$', + currency: 'USD', + }, + annualMonthlyFee: { + amount: feeAmount === 0 ? 0 : 1667, + amountFormatted: feeAmount === 0 ? '0.00' : '16.67', + currencySymbol: '$', + currency: 'USD', + }, + description: 'Seat-based pricing plan', + hasBaseFee: true, + isRecurring: true, + isDefault: false, + forPayerType: 'user', + publiclyVisible: true, + slug: `seat-plan-${id}`, + avatarUrl: '', + unitPrices: [ + { + name: 'seats', + blockSize: 1, + tiers, + }, + ], + features: [] as any[], + freeTrialEnabled: false, + freeTrialDays: 0, + __internal_toSnapshot: vi.fn(), + pathRoot: '', + reload: vi.fn(), + } as const; + }; + + const setup = async (plan: ReturnType) => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withBilling(); + }); + + props.setProps({}); + + fixtures.clerk.billing.getStatements.mockRejectedValue(); + fixtures.clerk.billing.getPlans.mockResolvedValue({ data: [plan as any], total_count: 1 }); + fixtures.clerk.billing.getSubscription.mockRejectedValue(new Error('Unauthenticated')); + + const renderResult = render(, { wrapper }); + + await waitFor(() => { + expect(renderResult.getByRole('heading', { name: 'Seat Plan' })).toBeVisible(); + }); + + return renderResult; + }; + + it('renders only "Up to N seats" for one free capped tier', async () => { + const plan = createSeatPlan({ + id: 'plan_free_capped', + feeAmount: 0, + tiers: [ + { + id: 'tier_1', + startsAtBlock: 1, + endsAfterBlock: 5, + feePerBlock: { + amount: 0, + amountFormatted: '0.00', + currencySymbol: '$', + currency: 'USD', + }, + }, + ], + }); + + const { getByText, queryByText } = await setup(plan); + + expect(getByText('Up to 5 seats')).toBeVisible(); + expect(queryByText('$5/mo per seat')).not.toBeInTheDocument(); + }); + + it('renders per-seat price plus "Up to N seats" for one paid capped tier', async () => { + const plan = createSeatPlan({ + id: 'plan_paid_capped', + feeAmount: 2000, + tiers: [ + { + id: 'tier_1', + startsAtBlock: 1, + endsAfterBlock: 5, + feePerBlock: { + amount: 500, + amountFormatted: '5.00', + currencySymbol: '$', + currency: 'USD', + }, + }, + ], + }); + + const { getByText } = await setup(plan); + + expect(getByText('$5/mo per seat')).toBeVisible(); + expect(getByText('Up to 5 seats')).toBeVisible(); + }); + + it('renders only "Unlimited seats" for one free uncapped tier', async () => { + const plan = createSeatPlan({ + id: 'plan_free_unlimited', + feeAmount: 0, + tiers: [ + { + id: 'tier_1', + startsAtBlock: 1, + endsAfterBlock: null, + feePerBlock: { + amount: 0, + amountFormatted: '0.00', + currencySymbol: '$', + currency: 'USD', + }, + }, + ], + }); + + const { getByText, queryByText } = await setup(plan); + + expect(getByText('Unlimited seats')).toBeVisible(); + expect(queryByText('$5/mo per seat')).not.toBeInTheDocument(); + }); + + it('renders per-seat price plus "Unlimited seats" for one paid uncapped tier', async () => { + const plan = createSeatPlan({ + id: 'plan_paid_unlimited', + feeAmount: 2000, + tiers: [ + { + id: 'tier_1', + startsAtBlock: 1, + endsAfterBlock: null, + feePerBlock: { + amount: 500, + amountFormatted: '5.00', + currencySymbol: '$', + currency: 'USD', + }, + }, + ], + }); + + const { getByText } = await setup(plan); + + expect(getByText('$5/mo per seat')).toBeVisible(); + expect(getByText('Unlimited seats')).toBeVisible(); + }); + + it('renders included seats with tooltip and unlimited limit for free+additional uncapped tiers', async () => { + const plan = createSeatPlan({ + id: 'plan_two_tier_unlimited', + feeAmount: 2000, + tiers: [ + { + id: 'tier_included', + startsAtBlock: 1, + endsAfterBlock: 5, + feePerBlock: { + amount: 0, + amountFormatted: '0.00', + currencySymbol: '$', + currency: 'USD', + }, + }, + { + id: 'tier_additional', + startsAtBlock: 6, + endsAfterBlock: null, + feePerBlock: { + amount: 500, + amountFormatted: '5.00', + currencySymbol: '$', + currency: 'USD', + }, + }, + ], + }); + + const { getByText, findByText, userEvent } = await setup(plan); + + expect(getByText('5 seats included')).toBeVisible(); + expect(getByText('($5/mo for additional)')).toBeVisible(); + expect(getByText('Unlimited seats')).toBeVisible(); + + await userEvent.hover(getByText('($5/mo for additional)')); + + expect( + await findByText('First 5 seats are included in the plan. Additional seats are $5/month each.'), + ).toBeVisible(); + }); + + it('renders included seats with tooltip and capped limit for free+additional capped tiers', async () => { + const plan = createSeatPlan({ + id: 'plan_two_tier_capped', + feeAmount: 0, + tiers: [ + { + id: 'tier_included', + startsAtBlock: 1, + endsAfterBlock: 5, + feePerBlock: { + amount: 0, + amountFormatted: '0.00', + currencySymbol: '$', + currency: 'USD', + }, + }, + { + id: 'tier_additional', + startsAtBlock: 6, + endsAfterBlock: 10, + feePerBlock: { + amount: 500, + amountFormatted: '5.00', + currencySymbol: '$', + currency: 'USD', + }, + }, + ], + }); + + const { getByText, findByText, userEvent } = await setup(plan); + + expect(getByText('5 seats included')).toBeVisible(); + expect(getByText('($5/mo for additional)')).toBeVisible(); + expect(getByText('Up to 10 seats')).toBeVisible(); + + await userEvent.hover(getByText('($5/mo for additional)')); + + expect(await findByText('Free for up to 5 seats. Additional seats are $5/month each.')).toBeVisible(); + }); +}); From af14952ad4f7d8d422d31b4ec8dc7ca4275c0501 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:39:23 -0600 Subject: [PATCH 7/8] Always show seat limits if unit prices are used --- packages/ui/src/components/PricingTable/PricingTableDefault.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/PricingTable/PricingTableDefault.tsx b/packages/ui/src/components/PricingTable/PricingTableDefault.tsx index 34c5e08c22f..1a5f87678b8 100644 --- a/packages/ui/src/components/PricingTable/PricingTableDefault.tsx +++ b/packages/ui/src/components/PricingTable/PricingTableDefault.tsx @@ -483,7 +483,7 @@ const CardFeaturesList = React.forwardRef padding: 0, })} > - {plan.unitPrices && (plan.hasBaseFee || plan.unitPrices[0].tiers.length > 1) ? ( + {plan.unitPrices && (plan.hasBaseFee || plan.unitPrices[0].tiers.length > 0) ? ( ) : null} {plan.features.slice(0, hasMoreFeatures ? (isCompact ? 3 : 8) : totalFeatures).map(feature => ( From 1af773b249bbc6bb80f1a4760299e6e9883ab3b0 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:42:45 -0600 Subject: [PATCH 8/8] fix(ui): Hide seat costs for plans with no base fee in features list --- packages/ui/src/components/PricingTable/PricingTableDefault.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/PricingTable/PricingTableDefault.tsx b/packages/ui/src/components/PricingTable/PricingTableDefault.tsx index 1a5f87678b8..cb58e8beb30 100644 --- a/packages/ui/src/components/PricingTable/PricingTableDefault.tsx +++ b/packages/ui/src/components/PricingTable/PricingTableDefault.tsx @@ -580,7 +580,7 @@ const CardFeaturesListSeatCost = ({ plan }: { plan: BillingPlanResource }) => { additionalTooltipText?: string; }> = []; - if (tier.feePerBlock.amount !== 0) { + if (tier.feePerBlock.amount !== 0 && plan.hasBaseFee) { rows.push({ elementId: 'seats', icon: User,