From c3a2e5f1db1fb94181ac6f4a756260fd2b63838f Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Thu, 21 May 2026 10:35:27 -0500 Subject: [PATCH 1/3] feat(localizations,shared,ui): Change invite members CTA based on available seats --- packages/localizations/src/en-US.ts | 1 + packages/shared/src/types/localization.ts | 1 + .../OrganizationProfile/InviteMembersForm.tsx | 25 +++++++++++-- packages/ui/src/utils/billingPlanSeats.ts | 37 +++++++++++++++++++ 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index be7dcff8482..7d91a056ccc 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -535,6 +535,7 @@ export const enUS: LocalizationResource = { detailsTitle__inviteFailed: 'The invitations could not be sent. There are already pending invitations for the following email addresses: {{email_addresses}}.', formButtonPrimary__continue: 'Send invitations', + formButtonPrimary__purchaseSeats: 'Purchase additional seats', selectDropdown__role: 'Select role', subtitle: 'Enter or paste one or more email addresses, separated by spaces or commas.', successMessage: 'Invitations successfully sent', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 3bb00ce6831..c53b1ecd020 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1110,6 +1110,7 @@ export type __internal_LocalizationResource = { successMessage: LocalizationValue; detailsTitle__inviteFailed: LocalizationValue<'email_addresses'>; formButtonPrimary__continue: LocalizationValue; + formButtonPrimary__purchaseSeats: LocalizationValue; selectDropdown__role: LocalizationValue; }; removeDomainPage: { diff --git a/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx b/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx index 3a8ecc68b8d..9a1f8119a6e 100644 --- a/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx +++ b/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx @@ -12,13 +12,18 @@ import { handleError } from '@/ui/utils/errorHandler'; import { createListFormat } from '@/ui/utils/passwordUtils'; import { useFormControl } from '@/ui/utils/useFormControl'; -import { useEnvironment } from '../../contexts'; +import { useEnvironment, useSubscription } from '../../contexts'; import { Flex } from '../../customizables'; import { useFetchRoles } from '../../hooks/useFetchRoles'; import type { LocalizationKey } from '../../localization'; import { localizationKeys, useLocalizations } from '../../localization'; import { mqu } from '../../styledSystem'; import { RoleSelect } from './MemberListTable'; +import { + getPaidSeatsUnitTier, + getSeatUnitPrice, + organizationAndInvitationsExceedsPurchasedSeats, +} from '@/utils/billingPlanSeats'; const isEmail = (str: string) => /^\S+@\S+\.\S+$/.test(str); @@ -37,6 +42,8 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => { keepPreviousData: true, }, }); + const { data: subscription } = useSubscription(); + const activeSubscriptionItem = subscription?.subscriptionItems.find(si => si.status === 'active'); const card = useCardState(); const { t, locale } = useLocalizations(); const [isValidUnsubmittedEmail, setIsValidUnsubmittedEmail] = useState(false); @@ -74,6 +81,14 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => { } = emailAddressField; const canSubmit = (!!emailAddressField.value.length || isValidUnsubmittedEmail) && !!roleField.value; + const emailAddresses = emailAddressField.value.split(','); + + const seatUnitPrice = activeSubscriptionItem ? getSeatUnitPrice(activeSubscriptionItem.plan) : null; + const paidSeatsTier = seatUnitPrice ? getPaidSeatsUnitTier(seatUnitPrice) : null; + const isPerSeatCostPlan = !!paidSeatsTier; + const mustPurchaseSeats = + isPerSeatCostPlan && + organizationAndInvitationsExceedsPurchasedSeats(activeSubscriptionItem, organization, emailAddresses.length); const onSubmit = (e: FormEvent) => { e.preventDefault(); @@ -84,7 +99,7 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => { const submittedData = new FormData(e.currentTarget); return organization .inviteMembers({ - emailAddresses: emailAddressField.value.split(','), + emailAddresses, role: submittedData.get('role') as string, }) .then(async () => { @@ -176,7 +191,11 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => { { + if (!unitPrice) { + return null; + } + + if (unitPrice.tiers.length === 1 && unitPrice.tiers[0].feePerBlock.amount > 0) { + return unitPrice.tiers[0]; + } + + if ( + unitPrice.tiers.length === 2 && + unitPrice.tiers[0].feePerBlock.amount === 0 && + unitPrice.tiers[1].feePerBlock.amount > 0 + ) { + return unitPrice.tiers[1]; + } + + return null; +}; + /** * Given a plan, return the seat limit for the plan, or undefined if the plan does not have a seat limit. */ @@ -114,3 +136,18 @@ export const organizationExceedsPlanSeatLimit = ( return organization.membersCount + organization.pendingInvitationsCount > seatLimit; }; + +export const organizationAndInvitationsExceedsPurchasedSeats = ( + subscriptionItem: BillingSubscriptionItemResource | undefined, + organization: OrganizationResource, + invitationsCount: number, +): boolean => { + if (!subscriptionItem || !subscriptionItem.seats || !subscriptionItem.seats.quantity) { + return false; + } + + return ( + organization.membersCount + organization.pendingInvitationsCount + invitationsCount > + subscriptionItem.seats.quantity + ); +}; From b73f589a3a2b6d1de930cb24d61b7621f25a44e3 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Thu, 21 May 2026 10:43:45 -0500 Subject: [PATCH 2/3] fix import --- .../OrganizationProfile/InviteMembersForm.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx b/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx index 9a1f8119a6e..38767d1691b 100644 --- a/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx +++ b/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx @@ -8,6 +8,11 @@ import { useCardState } from '@/ui/elements/contexts'; import { Form } from '@/ui/elements/Form'; import { FormButtonContainer } from '@/ui/elements/FormButtons'; import { TagInput } from '@/ui/elements/TagInput'; +import { + getPaidSeatsUnitTier, + getSeatUnitPrice, + organizationAndInvitationsExceedsPurchasedSeats, +} from '@/ui/utils/billingPlanSeats'; import { handleError } from '@/ui/utils/errorHandler'; import { createListFormat } from '@/ui/utils/passwordUtils'; import { useFormControl } from '@/ui/utils/useFormControl'; @@ -19,11 +24,6 @@ import type { LocalizationKey } from '../../localization'; import { localizationKeys, useLocalizations } from '../../localization'; import { mqu } from '../../styledSystem'; import { RoleSelect } from './MemberListTable'; -import { - getPaidSeatsUnitTier, - getSeatUnitPrice, - organizationAndInvitationsExceedsPurchasedSeats, -} from '@/utils/billingPlanSeats'; const isEmail = (str: string) => /^\S+@\S+\.\S+$/.test(str); From 736757190c4006f75d49353a0139449edcb970dd Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Tue, 26 May 2026 12:06:31 -0500 Subject: [PATCH 3/3] tests(ui): Add unit tests --- .../utils/__tests__/billingPlanSeats.test.ts | 80 ++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/utils/__tests__/billingPlanSeats.test.ts b/packages/ui/src/utils/__tests__/billingPlanSeats.test.ts index 6c6c8321c95..cfe0855da8c 100644 --- a/packages/ui/src/utils/__tests__/billingPlanSeats.test.ts +++ b/packages/ui/src/utils/__tests__/billingPlanSeats.test.ts @@ -1,7 +1,19 @@ -import type { BillingMoneyAmount, BillingPaymentTotals, BillingPerUnitTotal } from '@clerk/shared/types'; +import type { + BillingMoneyAmount, + BillingPaymentTotals, + BillingPerUnitTotal, + BillingPlanUnitPrice, + BillingSubscriptionItemResource, + OrganizationResource, +} from '@clerk/shared/types'; import { describe, expect, test } from 'vitest'; -import { getSeatsPerUnitTotal, summarizeSeatCharges } from '../billingPlanSeats'; +import { + getPaidSeatsUnitTier, + getSeatsPerUnitTotal, + organizationAndInvitationsExceedsPurchasedSeats, + summarizeSeatCharges, +} from '../billingPlanSeats'; const money = (amount: number): BillingMoneyAmount => ({ amount, @@ -121,3 +133,67 @@ describe('summarizeSeatCharges', () => { expect(summary!.included).toBe(0); }); }); + +describe('getPaidSeatsUnitTier', () => { + test('returns the paid tier from a seats unit price with included seats', () => { + const paidTier = { + id: 'tier_paid', + startsAtBlock: 4, + endsAfterBlock: null, + feePerBlock: money(500), + }; + const unitPrice: BillingPlanUnitPrice = { + name: 'seats', + blockSize: 1, + tiers: [ + { + id: 'tier_included', + startsAtBlock: 1, + endsAfterBlock: 3, + feePerBlock: money(0), + }, + paidTier, + ], + }; + + expect(getPaidSeatsUnitTier(unitPrice)).toBe(paidTier); + }); +}); + +describe('organizationAndInvitationsExceedsPurchasedSeats', () => { + test('returns true when members, pending invitations, and invitations exceed seats entitlements', () => { + const subscriptionItem = { + seats: { quantity: 4 }, + } as BillingSubscriptionItemResource; + const organization = { + membersCount: 2, + pendingInvitationsCount: 1, + } as OrganizationResource; + + expect(organizationAndInvitationsExceedsPurchasedSeats(subscriptionItem, organization, 2)).toBe(true); + }); + + test('returns false when members, pending invitations, and invitations equal seats entitlements', () => { + const subscriptionItem = { + seats: { quantity: 5 }, + } as BillingSubscriptionItemResource; + const organization = { + membersCount: 2, + pendingInvitationsCount: 1, + } as OrganizationResource; + + expect(organizationAndInvitationsExceedsPurchasedSeats(subscriptionItem, organization, 2)).toBe(false); + }); + + test('returns false when members, pending invitations, and invitations are below seats entitlements', () => { + const subscriptionItem = { + seats: { quantity: 10 }, + } as BillingSubscriptionItemResource; + const organization = { + membersCount: 2, + pendingInvitationsCount: 1, + } as OrganizationResource; + + expect(organizationAndInvitationsExceedsPurchasedSeats(subscriptionItem, organization, 2)).toBe(false); + }); +});