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/19] 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 0e036d2542498742f387f255e87f7acd991c99a2 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:37:22 -0500 Subject: [PATCH 02/19] feat(clerk-js,localizations,shared,ui): Render seat costs in PricingTable (#7917) --- packages/clerk-js/sandbox/scenarios/index.ts | 1 + .../sandbox/scenarios/pricing-table-sbb.ts | 371 ++++++++++++++++++ packages/localizations/src/en-US.ts | 17 + packages/shared/src/types/localization.ts | 17 + .../components/PricingTable/PricingTable.tsx | 1 + .../PricingTable/PricingTableDefault.tsx | 215 +++++++++- .../__tests__/PricingTable.test.tsx | 270 +++++++++++++ 7 files changed, 886 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..295b1b3a983 --- /dev/null +++ b/packages/clerk-js/sandbox/scenarios/pricing-table-sbb.ts @@ -0,0 +1,371 @@ +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), + }, + ], + }, + ], + }, + { + 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[], + }); + }); + + return { + description: 'PricingTable with seat-based billing plans', + handlers: [plansHandler, subscriptionHandler, paymentMethodsHandler, ...clerkHandlers], + initialState: { session, user }, + name: 'pricing-table-sbb', + }; +} diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 70fe35a6162..0d81f8920a9 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -121,6 +121,8 @@ export const enUS: LocalizationResource = { manage: 'Manage', manageSubscription: 'Manage subscription', month: 'Month', + monthAbbreviation: 'mo', + monthPerUnit: 'Month per {{unitName}}', monthly: 'Monthly', pastDue: 'Past due', pay: 'Pay {{amount}}', @@ -143,6 +145,19 @@ 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', + includedSeats: '{{includedSeats}} seats included', + additionalSeats: '({{additionalTierFeePerBlockAmount}}/{{periodAbbreviation}} for additional)', + unlimitedSeats: 'Unlimited seats', + 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', @@ -175,6 +190,8 @@ export const enUS: LocalizationResource = { viewFeatures: 'View features', viewPayment: 'View payment', year: 'Year', + yearAbbreviation: 'yr', + 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 e9d6db10850..0f3659e52fd 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -175,7 +175,11 @@ 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; manage: LocalizationValue; @@ -257,6 +261,19 @@ export type __internal_LocalizationResource = { pricingTable: { billingCycle: LocalizationValue; included: LocalizationValue; + seatCost: { + freeUpToSeats: LocalizationValue<'endsAfterBlock'>; + upToSeats: LocalizationValue<'endsAfterBlock'>; + perSeat: LocalizationValue<'feePerBlockAmount' | 'periodAbbreviation'>; + includedSeats: LocalizationValue<'includedSeats'>; + additionalSeats: LocalizationValue<'additionalTierFeePerBlockAmount' | 'periodAbbreviation'>; + unlimitedSeats: LocalizationValue; + tooltip: { + freeForUpToSeats: LocalizationValue<'endsAfterBlock'>; + additionalSeatsEach: LocalizationValue<'feePerBlockAmount' | 'period'>; + firstSeatsIncludedInPlan: LocalizationValue<'endsAfterBlock'>; + }; + }; }; checkout: { title: 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..cb58e8beb30 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'; @@ -20,8 +25,9 @@ import { SimpleButton, Span, Text, + useLocalizations, } from '../../customizables'; -import { Check, Plus } 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'; @@ -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 > 0) ? ( + + ) : null} {plan.features.slice(0, hasMoreFeatures ? (isCompact ? 3 : 8) : totalFeatures).map(feature => ( ); }); + +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 seatRows = 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)}`; + 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]; + const rows: Array<{ + elementId: string; + icon: typeof User | typeof Users; + text: ReturnType; + additionalText?: ReturnType; + additionalTooltipText?: string; + }> = []; + + if (tier.feePerBlock.amount !== 0 && plan.hasBaseFee) { + rows.push({ + elementId: 'seats', + icon: User, + text: localizationKeys('billing.pricingTable.seatCost.perSeat', { + feePerBlockAmount: formatTierFee(tier), + periodAbbreviation, + }), + }); + } + + rows.push({ + elementId: rows.length ? 'seats-limit' : 'seats', + icon: Users, + text: getCapacityText(tier.endsAfterBlock), + }); + + return rows; + } + + 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 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 [ + { + 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 (!seatRows?.length) { + return null; + } + + return ( + <> + {seatRows.map(row => ( + ({ + display: 'flex', + alignItems: 'baseline', + gap: t.space.$2, + margin: 0, + padding: 0, + })} + > + ({ + 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 c0052f1a00d65fbdaac746f1b4f74acffb2c372e Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:43:49 -0500 Subject: [PATCH 03/19] feat(clerk-js,localizations,msw,shared,ui): Add display for member limits (#7920) --- 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 | 46 +++++++++++- 6 files changed, 125 insertions(+), 4 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 f906e8b43ad..b232d324123 100644 --- a/packages/clerk-js/sandbox/scenarios/index.ts +++ b/packages/clerk-js/sandbox/scenarios/index.ts @@ -1,3 +1,4 @@ export { UserButtonSignedIn } from './user-button-signed-in'; export { CheckoutAccountCredit } from './checkout-account-credit'; +export { OrgProfileSeatLimit } from './org-profile-seat-limit'; export { PricingTableSBB } from './pricing-table-sbb'; 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 0d81f8920a9..324507302fc 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -474,6 +474,7 @@ export const enUS: LocalizationResource = { start: { headerTitle__general: 'General', headerTitle__members: 'Members', + membershipSeatUsageLabel: '{{count}} of {{limit}} seats used', 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 0f3659e52fd..a4f47119d33 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1022,6 +1022,7 @@ export type __internal_LocalizationResource = { badge__manualInvitation: LocalizationValue; start: { headerTitle__members: LocalizationValue; + 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 2b05de8fad8..f82a72ed392 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx @@ -2,6 +2,7 @@ import { useOrganization } from '@clerk/shared/react'; import { useState } from 'react'; import { useFetchRoles } from '@/hooks/useFetchRoles'; +import { Users } from '@/icons'; import { Alert } from '@/ui/elements/Alert'; import { Animated } from '@/ui/elements/Animated'; import { Card } from '@/ui/elements/Card'; @@ -11,7 +12,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'; @@ -33,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 @@ -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 && organization && organization.maxAllowedMemberships > 0 ? ( + ({ + 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, + })} + > + + + + + ) : null} ); }); From 192be6e05b1585f44117d5f459417597f453e853 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:08:42 -0500 Subject: [PATCH 04/19] fix(ui): Assert non-null fee --- packages/ui/src/components/Checkout/CheckoutForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/Checkout/CheckoutForm.tsx b/packages/ui/src/components/Checkout/CheckoutForm.tsx index 7fc80d0979f..41e6c06f354 100644 --- a/packages/ui/src/components/Checkout/CheckoutForm.tsx +++ b/packages/ui/src/components/Checkout/CheckoutForm.tsx @@ -42,7 +42,7 @@ export const CheckoutForm = withCardStateProvider(() => { const fee = planPeriod === 'month' - ? plan.fee + ? plan.fee! : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion plan.annualMonthlyFee!; From d5b41a115ccff3fd1caee19c990ef25801e44a98 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:27:04 -0500 Subject: [PATCH 05/19] feat(clerk-js,ui): Show SBB totals in Checkout (#8005) --- .../sandbox/scenarios/checkout-seats.ts | 341 ++++++++++++++++++ packages/clerk-js/sandbox/scenarios/index.ts | 1 + .../src/components/Checkout/CheckoutForm.tsx | 17 +- packages/ui/src/elements/LineItems.tsx | 30 +- 4 files changed, 377 insertions(+), 12 deletions(-) create mode 100644 packages/clerk-js/sandbox/scenarios/checkout-seats.ts diff --git a/packages/clerk-js/sandbox/scenarios/checkout-seats.ts b/packages/clerk-js/sandbox/scenarios/checkout-seats.ts new file mode 100644 index 00000000000..5e553c07cdb --- /dev/null +++ b/packages/clerk-js/sandbox/scenarios/checkout-seats.ts @@ -0,0 +1,341 @@ +import { + clerkHandlers, + http, + HttpResponse, + EnvironmentService, + SessionService, + setClerkState, + type MockScenario, + UserService, +} from '@clerk/msw'; + +export function CheckoutSeats(): MockScenario { + const user = UserService.create(); + const session = SessionService.create(user); + + 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 checkoutAccountCreditHandler = http.post('https://*.clerk.accounts.dev/v1/me/billing/checkouts', () => { + return HttpResponse.json({ + response: { + object: 'commerce_checkout', + id: 'string', + plan: { + object: 'commerce_plan', + id: 'string', + name: 'Pro', + fee: { + amount: 0, + amount_formatted: '25.00', + currency: 'string', + currency_symbol: '$', + }, + annual_monthly_fee: { + amount: 0, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + annual_fee: { + amount: 0, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + description: null, + is_default: true, + is_recurring: true, + publicly_visible: true, + has_base_fee: true, + for_payer_type: 'string', + slug: 'string', + avatar_url: null, + free_trial_enabled: true, + free_trial_days: null, + features: [ + { + object: 'feature', + id: 'string', + name: 'string', + description: null, + slug: 'string', + avatar_url: null, + }, + ], + }, + plan_period: 'month', + payer: { + object: 'commerce_payer', + id: 'string', + instance_id: 'string', + user_id: null, + first_name: null, + last_name: null, + email: null, + organization_id: null, + organization_name: null, + image_url: 'https://example.com', + created_at: 1, + updated_at: 1, + }, + payment_method: { + object: 'commerce_payment_method', + id: 'string', + payer_id: 'string', + payment_type: 'card', + is_default: true, + gateway: 'string', + gateway_external_id: 'string', + gateway_external_account_id: null, + last4: null, + status: 'active', + wallet_type: null, + card_type: null, + expiry_year: null, + expiry_month: null, + created_at: 1, + updated_at: 1, + is_removable: true, + }, + external_gateway_id: 'string', + status: 'needs_confirmation', + totals: { + subtotal: { + amount: 4500, + amount_formatted: '45.00', + currency: 'string', + currency_symbol: '$', + }, + tax_total: { + amount: 1, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + grand_total: { + amount: 1, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + total_due_after_free_trial: { + amount: 1, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + total_due_now: { + amount: 4500, + amount_formatted: '45.00', + currency: 'string', + currency_symbol: '$', + }, + past_due: null, + credit: { + amount: 1, + amount_formatted: '5.00', + currency: 'string', + currency_symbol: '$', + }, + per_unit_totals: [ + { + name: 'seats', + block_size: 1, + tiers: [ + { + quantity: 10, + fee_per_block: { + amount: 0, + amount_formatted: '0.00', + currency: 'USD', + currency_symbol: '$', + }, + total: { + amount: 0, + amount_formatted: '0.00', + currency: 'USD', + currency_symbol: '$', + }, + }, + { + quantity: 2, + fee_per_block: { + amount: 1000, + amount_formatted: '10.00', + currency: 'USD', + currency_symbol: '$', + }, + total: { + amount: 2000, + amount_formatted: '20.00', + currency: 'USD', + currency_symbol: '$', + }, + }, + ], + }, + ], + }, + subscription_item: { + object: 'commerce_subscription_item', + id: 'string', + instance_id: 'string', + status: 'active', + credit: { + amount: { + amount: 1, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + cycle_days_remaining: 1, + cycle_days_total: 1, + cycle_remaining_percent: 1, + }, + plan_id: 'string', + price_id: 'string', + plan: { + object: 'commerce_plan', + id: 'string', + name: 'string', + fee: { + amount: 0, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + annual_monthly_fee: { + amount: 0, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + annual_fee: { + amount: 0, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + description: null, + is_default: true, + is_recurring: true, + publicly_visible: true, + has_base_fee: true, + for_payer_type: 'string', + slug: 'string', + avatar_url: null, + free_trial_enabled: true, + free_trial_days: null, + features: [ + { + object: 'feature', + id: 'string', + name: 'string', + description: null, + slug: 'string', + avatar_url: null, + }, + ], + }, + plan_period: 'month', + payment_method_id: 'string', + payment_method: { + object: 'commerce_payment_method', + id: 'string', + payer_id: 'string', + payment_type: 'card', + is_default: true, + gateway: 'string', + gateway_external_id: 'string', + gateway_external_account_id: null, + last4: null, + status: 'active', + wallet_type: null, + card_type: null, + expiry_year: null, + expiry_month: null, + created_at: 1, + updated_at: 1, + is_removable: true, + }, + lifetime_paid: { + amount: 0, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + amount: { + amount: 0, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + next_payment: { + amount: { + amount: 0, + amount_formatted: 'string', + currency: 'string', + currency_symbol: 'string', + }, + date: 1, + }, + payer_id: 'string', + payer: { + object: 'commerce_payer', + id: 'string', + instance_id: 'string', + user_id: null, + first_name: null, + last_name: null, + email: null, + organization_id: null, + organization_name: null, + image_url: 'https://example.com', + created_at: 1, + updated_at: 1, + }, + is_free_trial: true, + period_start: 1, + period_end: null, + proration_date: 'string', + canceled_at: null, + past_due_at: null, + ended_at: null, + created_at: 1, + updated_at: 1, + }, + plan_period_start: 1, + is_immediate_plan_change: true, + free_trial_ends_at: 1, + needs_payment_method: true, + }, + }); + }); + + return { + description: 'Checkout with seats', + handlers: [checkoutAccountCreditHandler, subscriptionHandler, paymentMethodsHandler, ...clerkHandlers], + initialState: { session, user }, + name: 'checkout-seats', + }; +} diff --git a/packages/clerk-js/sandbox/scenarios/index.ts b/packages/clerk-js/sandbox/scenarios/index.ts index 1ffb1294aab..eb8717b5deb 100644 --- a/packages/clerk-js/sandbox/scenarios/index.ts +++ b/packages/clerk-js/sandbox/scenarios/index.ts @@ -1,5 +1,6 @@ export { UserButtonSignedIn } from './user-button-signed-in'; export { CheckoutAccountCredit } from './checkout-account-credit'; +export { CheckoutSeats } from './checkout-seats'; export { OrgProfileSeatLimit } from './org-profile-seat-limit'; export { PricingTableSBB } from './pricing-table-sbb'; export { AnnualOnlyPlans } from './annual-only-plans'; diff --git a/packages/ui/src/components/Checkout/CheckoutForm.tsx b/packages/ui/src/components/Checkout/CheckoutForm.tsx index 41e6c06f354..7832de74147 100644 --- a/packages/ui/src/components/Checkout/CheckoutForm.tsx +++ b/packages/ui/src/components/Checkout/CheckoutForm.tsx @@ -46,6 +46,21 @@ export const CheckoutForm = withCardStateProvider(() => { : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion plan.annualMonthlyFee!; + const descriptionElements = []; + if (planPeriod === 'annual') { + descriptionElements.push(localizationKeys('billing.billedAnnually')); + } + const seatUnitPrice = plan.unitPrices?.find(unitPrice => unitPrice.name.toLowerCase() === 'seats'); + if (seatUnitPrice && seatUnitPrice.tiers.length === 1 && seatUnitPrice.tiers[0].feePerBlock.amount === 0) { + descriptionElements.push( + seatUnitPrice.tiers[0].endsAfterBlock + ? localizationKeys('billing.pricingTable.seatCost.upToSeats', { + endsAfterBlock: seatUnitPrice.tiers[0].endsAfterBlock, + }) + : localizationKeys('billing.pricingTable.seatCost.unlimitedSeats'), + ); + } + return ( { diff --git a/packages/ui/src/elements/LineItems.tsx b/packages/ui/src/elements/LineItems.tsx index 119d93e0a52..ab5173392ba 100644 --- a/packages/ui/src/elements/LineItems.tsx +++ b/packages/ui/src/elements/LineItems.tsx @@ -82,7 +82,7 @@ function Group({ children, borderTop = false, variant = 'primary' }: GroupProps) interface TitleProps { title?: string | LocalizationKey; - description?: string | LocalizationKey; + description?: string | LocalizationKey | (string | LocalizationKey)[]; icon?: React.ComponentType; badge?: React.ReactNode; } @@ -94,6 +94,9 @@ const Title = React.forwardRef(({ title, descr } const { variant } = context; const textVariant = variant === 'primary' ? 'subtitle' : 'caption'; + + const descriptionElements = description ? (Array.isArray(description) ? description : [description]) : []; + return (
(({ title, descr {badge} ) : null} - {description ? ( - ({ - fontSize: t.fontSizes.$sm, - color: t.colors.$colorMutedForeground, - })} - /> + {descriptionElements.length > 0 ? ( + <> + {descriptionElements.map((el, i) => ( + ({ + fontSize: t.fontSizes.$sm, + color: t.colors.$colorMutedForeground, + })} + /> + ))} + ) : null}
); @@ -184,7 +192,7 @@ function Description({ text, prefix, suffix, truncateText = false, copyText = fa sx={t => ({ display: 'inline-flex', justifyContent: 'flex-end', - alignItems: 'center', + alignItems: 'end', gap: t.space.$1, minWidth: '0', })} From 403efb1891a9b67edbbc4d7ea0a9da09333a2003 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:53:11 -0500 Subject: [PATCH 06/19] feat(ui): Disable invite button when over maxAllowedMemberships (#8089) --- packages/localizations/src/en-US.ts | 3 +- .../OrganizationProfile/MembersActions.tsx | 58 +++++++++++++++---- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 95d14949135..0ca2b521bfa 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1037,7 +1037,8 @@ export const enUS: LocalizationResource = { organization_domain_blocked: undefined, organization_domain_common: undefined, organization_domain_exists_for_enterprise_connection: undefined, - organization_membership_quota_exceeded: undefined, + organization_membership_quota_exceeded: + 'You have reached your limit of organization memberships, including outstanding invitations.', organization_minimum_permissions_needed: undefined, organization_not_found_or_unauthorized: 'You are no longer a member of this organization. Please choose or create another one.', diff --git a/packages/ui/src/components/OrganizationProfile/MembersActions.tsx b/packages/ui/src/components/OrganizationProfile/MembersActions.tsx index c4fe5caf852..bf7824ef1af 100644 --- a/packages/ui/src/components/OrganizationProfile/MembersActions.tsx +++ b/packages/ui/src/components/OrganizationProfile/MembersActions.tsx @@ -1,4 +1,7 @@ +import { useMemo, type ReactNode } from 'react'; +import { useOrganization } from '@clerk/shared/react'; import { Animated } from '@/ui/elements/Animated'; +import { Tooltip } from '@/ui/elements/Tooltip'; import { useProtect } from '../../common'; import { Button, descriptors, Flex, localizationKeys } from '../../customizables'; @@ -11,6 +14,48 @@ type MembersActionsRowProps = { export const MembersActionsRow = ({ actionSlot }: MembersActionsRowProps) => { const canManageMemberships = useProtect({ permission: 'org:sys_memberships:manage' }); + const { organization } = useOrganization(); + + const isBelowLimit = useMemo(() => { + if (!organization) { + return false; + } + + // A value of 0 means unlimited memberships, thus the organization is always below the limit + if (organization.maxAllowedMemberships === 0) { + return true; + } + + return organization.membersCount + organization.pendingInvitationsCount < organization.maxAllowedMemberships; + }, [organization]); + + const inviteButton = ( +