From 340d3bfd24b4f3f135dbb760c3a5a2fbd09f70c7 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:58:56 -0600 Subject: [PATCH 01/14] feat(clerk-js,shared): Add parsing for SBB fields (#7785) --- .changeset/cute-ideas-appear.md | 6 + .../src/core/resources/BillingPlan.ts | 12 ++ .../src/core/resources/BillingSubscription.ts | 3 + packages/clerk-js/src/utils/billing.ts | 17 +++ packages/shared/src/types/billing.ts | 109 ++++++++++++++++++ packages/shared/src/types/json.ts | 75 ++++++++++++ 6 files changed, 222 insertions(+) create mode 100644 .changeset/cute-ideas-appear.md diff --git a/.changeset/cute-ideas-appear.md b/.changeset/cute-ideas-appear.md new file mode 100644 index 00000000000..9b1d6379498 --- /dev/null +++ b/.changeset/cute-ideas-appear.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +--- + +Add support for parsing seat-based billing fields from FAPI. diff --git a/packages/clerk-js/src/core/resources/BillingPlan.ts b/packages/clerk-js/src/core/resources/BillingPlan.ts index 6afbdbfe3b9..977e454cbd7 100644 --- a/packages/clerk-js/src/core/resources/BillingPlan.ts +++ b/packages/clerk-js/src/core/resources/BillingPlan.ts @@ -3,6 +3,7 @@ import type { BillingPayerResourceType, BillingPlanJSON, BillingPlanResource, + BillingPlanUnitPrice, } from '@clerk/shared/types'; import { billingMoneyAmountFromJSON } from '@/utils/billing'; @@ -24,6 +25,7 @@ export class BillingPlan extends BaseResource implements BillingPlanResource { slug!: string; avatarUrl: string | null = null; features!: Feature[]; + unitPrices?: BillingPlanUnitPrice[]; freeTrialDays!: number | null; freeTrialEnabled!: boolean; @@ -53,6 +55,16 @@ export class BillingPlan extends BaseResource implements BillingPlanResource { 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 Feature(feature)); + this.unitPrices = data.unit_prices?.map(unitPrice => ({ + name: unitPrice.name, + blockSize: unitPrice.block_size, + tiers: unitPrice.tiers.map(tier => ({ + id: tier.id, + startsAtBlock: tier.starts_at_block, + endsAfterBlock: tier.ends_after_block, + feePerBlock: billingMoneyAmountFromJSON(tier.fee_per_block), + })), + })); return this; } diff --git a/packages/clerk-js/src/core/resources/BillingSubscription.ts b/packages/clerk-js/src/core/resources/BillingSubscription.ts index 3b80c7dbe66..f463c82646b 100644 --- a/packages/clerk-js/src/core/resources/BillingSubscription.ts +++ b/packages/clerk-js/src/core/resources/BillingSubscription.ts @@ -2,6 +2,7 @@ import type { BillingMoneyAmount, BillingSubscriptionItemJSON, BillingSubscriptionItemResource, + BillingSubscriptionItemSeats, BillingSubscriptionJSON, BillingSubscriptionPlanPeriod, BillingSubscriptionResource, @@ -75,6 +76,7 @@ export class BillingSubscriptionItem extends BaseResource implements BillingSubs credit?: { amount: BillingMoneyAmount; }; + seats?: BillingSubscriptionItemSeats; isFreeTrial!: boolean; constructor(data: BillingSubscriptionItemJSON) { @@ -102,6 +104,7 @@ export class BillingSubscriptionItem extends BaseResource implements BillingSubs this.amount = data.amount ? billingMoneyAmountFromJSON(data.amount) : undefined; this.credit = data.credit && data.credit.amount ? { amount: billingMoneyAmountFromJSON(data.credit.amount) } : undefined; + this.seats = data.seats ? { quantity: data.seats.quantity } : undefined; this.isFreeTrial = this.withDefault(data.is_free_trial, false); return this; diff --git a/packages/clerk-js/src/utils/billing.ts b/packages/clerk-js/src/utils/billing.ts index a72868a859d..d795c2841f0 100644 --- a/packages/clerk-js/src/utils/billing.ts +++ b/packages/clerk-js/src/utils/billing.ts @@ -3,6 +3,8 @@ import type { BillingCheckoutTotalsJSON, BillingMoneyAmount, BillingMoneyAmountJSON, + BillingPerUnitTotal, + BillingPerUnitTotalJSON, BillingStatementTotals, BillingStatementTotalsJSON, } from '@clerk/shared/types'; @@ -16,6 +18,18 @@ export const billingMoneyAmountFromJSON = (data: BillingMoneyAmountJSON): Billin }; }; +const billingPerUnitTotalsFromJSON = (data: BillingPerUnitTotalJSON[]): BillingPerUnitTotal[] => { + return data.map(unitTotal => ({ + name: unitTotal.name, + blockSize: unitTotal.block_size, + tiers: unitTotal.tiers.map(tier => ({ + quantity: tier.quantity, + feePerBlock: billingMoneyAmountFromJSON(tier.fee_per_block), + total: billingMoneyAmountFromJSON(tier.total), + })), + })); +}; + export const billingTotalsFromJSON = ( data: T, ): T extends { total_due_now: BillingMoneyAmountJSON } ? BillingCheckoutTotals : BillingStatementTotals => { @@ -31,6 +45,9 @@ export const billingTotalsFromJSON = @@ -708,6 +813,10 @@ export interface BillingCheckoutTotals { * The amount of tax included in the checkout. */ taxTotal: BillingMoneyAmount; + /** + * Per-unit cost breakdown for this checkout (for example, seats). + */ + perUnitTotals?: BillingPerUnitTotal[]; /** * The amount that needs to be immediately paid to complete the checkout. */ diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 5ce69d083d4..233868082ef 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -596,6 +596,68 @@ export interface FeatureJSON extends ClerkResourceJSON { avatar_url: string | null; } +/** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + */ +export interface BillingSubscriptionItemSeatsJSON { + /** + * The number of seats available. `null` means unlimited. + */ + quantity: number | null; +} + +/** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + * + * Represents a single pricing tier for a unit type on a plan. + */ +export interface BillingPlanUnitPriceTierJSON extends ClerkResourceJSON { + id: string; + object: 'commerce_unit_price'; + starts_at_block: number; + /** + * `null` means unlimited. + */ + ends_after_block: number | null; + fee_per_block: BillingMoneyAmountJSON; +} + +/** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + * + * Represents unit pricing for a specific unit type (for example, seats) on a plan. + */ +export interface BillingPlanUnitPriceJSON { + name: string; + block_size: number; + tiers: BillingPlanUnitPriceTierJSON[]; +} + +/** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + * + * Represents the cost breakdown for a single tier in checkout totals. + */ +export interface BillingPerUnitTotalTierJSON { + /** + * `null` means unlimited. + */ + quantity: number | null; + fee_per_block: BillingMoneyAmountJSON; + total: BillingMoneyAmountJSON; +} + +/** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + * + * Represents the per-unit cost breakdown in checkout totals. + */ +export interface BillingPerUnitTotalJSON { + name: string; + block_size: number; + tiers: BillingPerUnitTotalTierJSON[]; +} + /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. */ @@ -617,6 +679,10 @@ export interface BillingPlanJSON extends ClerkResourceJSON { features?: FeatureJSON[]; free_trial_days?: number | null; free_trial_enabled?: boolean; + /** + * Per-unit pricing tiers for this plan (for example, seats). + */ + unit_prices?: BillingPlanUnitPriceJSON[]; } /** @@ -695,6 +761,11 @@ export interface BillingSubscriptionItemJSON extends ClerkResourceJSON { credit?: { amount: BillingMoneyAmountJSON; }; + /** + * Seat entitlement details for this subscription item. Only set for organization subscription items with + * seat-based billing. + */ + seats?: BillingSubscriptionItemSeatsJSON; plan: BillingPlanJSON; plan_period: BillingSubscriptionPlanPeriod; status: BillingSubscriptionStatus; @@ -751,6 +822,10 @@ export interface BillingCheckoutTotalsJSON { grand_total: BillingMoneyAmountJSON; subtotal: BillingMoneyAmountJSON; tax_total: BillingMoneyAmountJSON; + /** + * Per-unit cost breakdown for this checkout (for example, seats). + */ + per_unit_totals?: BillingPerUnitTotalJSON[]; total_due_now: BillingMoneyAmountJSON; credit: BillingMoneyAmountJSON | null; past_due: BillingMoneyAmountJSON | null; 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 02/14] 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 517ab55f0cb46ff78945e97358b2e34a2dccfeee Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:38:47 -0600 Subject: [PATCH 03/14] feat(clerk-js,localizations,msw,shared,ui): Add display for member limit usage --- packages/clerk-js/sandbox/scenarios/index.ts | 1 + .../scenarios/org-profile-seat-limit.ts | 74 +++++++++++++++++++ packages/localizations/src/en-US.ts | 1 + packages/msw/request-handlers.ts | 6 +- packages/shared/src/types/localization.ts | 1 + .../OrganizationMembers.tsx | 44 ++++++++++- 6 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 packages/clerk-js/sandbox/scenarios/org-profile-seat-limit.ts diff --git a/packages/clerk-js/sandbox/scenarios/index.ts b/packages/clerk-js/sandbox/scenarios/index.ts index 73ddfca0ce6..8f1c885bd20 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 { OrgProfileSeatLimit } from './org-profile-seat-limit'; diff --git a/packages/clerk-js/sandbox/scenarios/org-profile-seat-limit.ts b/packages/clerk-js/sandbox/scenarios/org-profile-seat-limit.ts new file mode 100644 index 00000000000..355fcb19db5 --- /dev/null +++ b/packages/clerk-js/sandbox/scenarios/org-profile-seat-limit.ts @@ -0,0 +1,74 @@ +import { + BillingService, + clerkHandlers, + EnvironmentService, + SessionService, + setClerkState, + type MockScenario, + UserService, + OrganizationService, +} from '@clerk/msw'; + +export function OrgProfileSeatLimit(): MockScenario { + const organization = OrganizationService.create({ maxAllowedMemberships: 10 }); + const user = UserService.create(); + user.organizationMemberships = [ + { + object: 'organization_membership', + id: 'orgmem_3004mVaZrB4yD63C9KuwTMWNKbj', + public_metadata: {}, + role: 'org:owner', + role_name: 'Owner', + permissions: [ + 'org:applications:create', + 'org:applications:manage', + 'org:applications:delete', + 'org:billing:read', + 'org:billing:manage', + 'org:config:read', + 'org:config:manage', + 'org:global:read', + 'org:global:manage', + 'org:instances:create', + 'org:instances:manage', + 'org:instances:delete', + 'org:restrictions:read', + 'org:restrictions:manage', + 'org:secrets:manage', + 'org:users:imp', + 'org:sys_profile:manage', + 'org:sys_profile:delete', + 'org:sys_billing:read', + 'org:sys_billing:manage', + 'org:sys_domains:read', + 'org:sys_domains:manage', + 'org:sys_memberships:read', + 'org:sys_memberships:manage', + ], + created_at: 1752751315275, + updated_at: 1752751315275, + organization, + }, + ]; + const session = SessionService.create(user); + const plans = BillingService.createDefaultPlans(); + const subscription = BillingService.createSubscription(plans[1]); + + setClerkState({ + environment: EnvironmentService.MULTI_SESSION, + session, + user, + organization, + billing: { + plans, + subscription, + }, + }); + + return { + description: 'OrganizationProfile with a seat limit', + handlers: clerkHandlers, + initialState: { session, user, organization }, + name: 'org-profile-seat-limit', + }; +} diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index d2134717645..2afe698a3d0 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -454,6 +454,7 @@ export const enUS: LocalizationResource = { start: { headerTitle__general: 'General', headerTitle__members: 'Members', + headerSubtitle__members: '{{count}} of {{limit}} seats taken', profileSection: { primaryButton: 'Update profile', title: 'Organization Profile', diff --git a/packages/msw/request-handlers.ts b/packages/msw/request-handlers.ts index 3351ab3bf00..6958a8eea3e 100644 --- a/packages/msw/request-handlers.ts +++ b/packages/msw/request-handlers.ts @@ -1114,8 +1114,10 @@ export const clerkHandlers = [ const membership = (currentUser as any).organizationMemberships.find((m: any) => m.organization?.id === orgId); if (membership) { return createNoStoreResponse({ - data: [SessionService.serialize(membership)], - total_count: 1, + response: { + data: [SessionService.serialize(membership)], + total_count: 1, + }, }); } } diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index d81be772c21..c199d95e69e 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1002,6 +1002,7 @@ export type __internal_LocalizationResource = { badge__manualInvitation: LocalizationValue; start: { headerTitle__members: LocalizationValue; + headerSubtitle__members: LocalizationValue<'count' | 'limit'>; headerTitle__general: LocalizationValue; profileSection: { title: LocalizationValue; diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx index 2b05de8fad8..29fec0d0eaa 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx @@ -11,7 +11,7 @@ import { Tab, TabPanel, TabPanels, Tabs, TabsList } from '@/ui/elements/Tabs'; import { NotificationCountBadge, useProtect } from '../../common'; import { useEnvironment } from '../../contexts'; -import { Col, descriptors, Flex, localizationKeys } from '../../customizables'; +import { Box, Col, descriptors, Flex, Icon, localizationKeys, Text } from '../../customizables'; import { Action } from '../../elements/Action'; import { mqu } from '../../styledSystem'; import { ActiveMembersList } from './ActiveMembersList'; @@ -19,6 +19,7 @@ import { MembersActionsRow } from './MembersActions'; import { MembersSearch } from './MembersSearch'; import { OrganizationMembersTabInvitations } from './OrganizationMembersTabInvitations'; import { OrganizationMembersTabRequests } from './OrganizationMembersTabRequests'; +import { Users } from '@/icons'; export const ACTIVE_MEMBERS_PAGE_SIZE = 10; @@ -57,6 +58,7 @@ export const OrganizationMembers = withCardStateProvider(() => { elementDescriptor={descriptors.profilePage} elementId={descriptors.profilePage.setId('organizationMembers')} gap={4} + sx={theme => ({ paddingBottom: theme.space.$13 })} > @@ -173,6 +175,46 @@ export const OrganizationMembers = withCardStateProvider(() => { + + {canReadMemberships && !!memberships?.count && ( + ({ + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + backgroundColor: theme.colors.$colorBackground, + borderTop: `1px solid ${theme.colors.$borderAlpha100}`, + paddingInline: theme.space.$4, + height: theme.space.$13, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + })} + > + ({ + display: 'inline-flex', + alignItems: 'center', + gap: t.space.$2, + })} + > + + + + + )} ); }); 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 04/14] 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 55ddfb5702ce2f19a790d1d028282a79b836d6dd Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:19:44 -0600 Subject: [PATCH 05/14] feat(): Change to membershipSeatUsageLabel --- packages/localizations/src/en-US.ts | 2 +- packages/shared/src/types/localization.ts | 2 +- .../src/components/OrganizationProfile/OrganizationMembers.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 2afe698a3d0..aec483ee909 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -454,7 +454,7 @@ export const enUS: LocalizationResource = { start: { headerTitle__general: 'General', headerTitle__members: 'Members', - headerSubtitle__members: '{{count}} of {{limit}} seats taken', + membershipSeatUsageLabel: '{{count}} of {{limit}} seats taken', profileSection: { primaryButton: 'Update profile', title: 'Organization Profile', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index c199d95e69e..533fb5b8198 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1002,7 +1002,7 @@ export type __internal_LocalizationResource = { badge__manualInvitation: LocalizationValue; start: { headerTitle__members: LocalizationValue; - headerSubtitle__members: LocalizationValue<'count' | 'limit'>; + membershipSeatUsageLabel: LocalizationValue<'count' | 'limit'>; headerTitle__general: LocalizationValue; profileSection: { title: LocalizationValue; diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx index 29fec0d0eaa..c2df37e3114 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx @@ -207,7 +207,7 @@ export const OrganizationMembers = withCardStateProvider(() => { Date: Tue, 24 Feb 2026 13:37:16 -0600 Subject: [PATCH 06/14] 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 07/14] 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 08/14] 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 cc8bbba81995de0c768b4cd87305e874a6e1a21e Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:04:49 -0600 Subject: [PATCH 09/14] fix(localizations): Update text --- packages/localizations/src/en-US.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index aec483ee909..9b5641bfe59 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -454,7 +454,7 @@ export const enUS: LocalizationResource = { start: { headerTitle__general: 'General', headerTitle__members: 'Members', - membershipSeatUsageLabel: '{{count}} of {{limit}} seats taken', + membershipSeatUsageLabel: '{{count}} of {{limit}} seats used', profileSection: { primaryButton: 'Update profile', title: 'Organization Profile', From 3e31113783770e8615a0b47989c6ab8f3f2bdb44 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:05:22 -0600 Subject: [PATCH 10/14] fix(ui): Use org maxAllowedMemberships, not env --- .../components/OrganizationProfile/OrganizationMembers.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx index c2df37e3114..c02de5cd466 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx @@ -34,7 +34,7 @@ export const OrganizationMembers = withCardStateProvider(() => { const [query, setQuery] = useState(''); const [search, setSearch] = useState(''); - const { membershipRequests, memberships, invitations } = useOrganization({ + const { membershipRequests, memberships, invitations, organization } = useOrganization({ membershipRequests: isDomainsEnabled || undefined, invitations: canManageMemberships || undefined, memberships: canReadMemberships @@ -44,6 +44,7 @@ export const OrganizationMembers = withCardStateProvider(() => { } : undefined, }); + organization?.maxAllowedMemberships; if (canManageMemberships === null) { return null; @@ -176,7 +177,7 @@ export const OrganizationMembers = withCardStateProvider(() => { - {canReadMemberships && !!memberships?.count && ( + {canReadMemberships && !!memberships?.count && organization?.maxAllowedMemberships && ( ({ position: 'absolute', @@ -209,7 +210,7 @@ export const OrganizationMembers = withCardStateProvider(() => { colorScheme='inherit' localizationKey={localizationKeys('organizationProfile.start.membershipSeatUsageLabel', { count: memberships.count + (invitations?.count ?? 0), - limit: organizationSettings.maxAllowedMemberships, + limit: organization.maxAllowedMemberships, })} /> 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 11/14] 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 12/14] 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 13/14] 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, From 69e1f771e9634b164061a80edbd498fe6cdc7875 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:59:49 -0600 Subject: [PATCH 14/14] chore(): staging key --- packages/clerk-js/sandbox/template.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html index d8ff8041392..85c47b3503f 100644 --- a/packages/clerk-js/sandbox/template.html +++ b/packages/clerk-js/sandbox/template.html @@ -372,7 +372,7 @@