From 3a00020fc68e3d64749572e19cb1c882225a2fb2 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Thu, 7 May 2026 09:33:03 -0500 Subject: [PATCH 1/2] feat(clerk-js,react,shared,ui): Add support for org invite to checkout flow --- .../src/core/modules/billing/namespace.ts | 4 +- .../src/core/modules/checkout/instance.ts | 16 +- .../react/src/components/CheckoutButton.tsx | 2 + .../__tests__/CheckoutButton.test.tsx | 2 + packages/shared/src/errors/clerkApiError.ts | 2 + packages/shared/src/errors/parseError.ts | 2 + packages/shared/src/react/contexts.tsx | 1 + .../shared/src/react/hooks/useCheckout.ts | 6 +- packages/shared/src/types/billing.ts | 3 + packages/shared/src/types/clerk.ts | 3 + packages/shared/src/types/errors.ts | 4 + .../src/components/Checkout/CheckoutPage.tsx | 5 +- .../Checkout/__tests__/Checkout.test.tsx | 33 ++++ .../OrganizationProfile/InviteMembersForm.tsx | 150 +++++++++++------- .../OrganizationProfile/MembersActions.tsx | 9 +- packages/ui/src/contexts/components/Plans.tsx | 16 +- .../src/lazyModules/MountedCheckoutDrawer.tsx | 1 + packages/ui/src/utils/billingPlanSeats.ts | 18 +++ .../src/utils/getClosestProfileScrollBox.ts | 22 ++- 19 files changed, 225 insertions(+), 74 deletions(-) diff --git a/packages/clerk-js/src/core/modules/billing/namespace.ts b/packages/clerk-js/src/core/modules/billing/namespace.ts index f055a531d5c..ea628adb418 100644 --- a/packages/clerk-js/src/core/modules/billing/namespace.ts +++ b/packages/clerk-js/src/core/modules/billing/namespace.ts @@ -36,8 +36,8 @@ export class Billing implements BillingNamespace { } getPlans = async (params?: GetPlansParams): Promise> => { - const { for: forParam, ...safeParams } = params || {}; - const searchParams = { ...safeParams, payer_type: forParam === 'organization' ? 'org' : 'user' }; + const { for: forParam, org_id, min_seats, ...safeParams } = params || {}; + const searchParams = { ...safeParams, payer_type: forParam === 'organization' ? 'org' : 'user', org_id, min_seats }; return await BaseResource._fetch({ path: `${Billing.#pathRoot}/plans`, method: 'GET', diff --git a/packages/clerk-js/src/core/modules/checkout/instance.ts b/packages/clerk-js/src/core/modules/checkout/instance.ts index 85224308462..2feca5a47d5 100644 --- a/packages/clerk-js/src/core/modules/checkout/instance.ts +++ b/packages/clerk-js/src/core/modules/checkout/instance.ts @@ -9,9 +9,15 @@ type CheckoutKey = string & { readonly __tag: 'CheckoutKey' }; /** * Generate cache key for checkout instance */ -function cacheKey(options: { userId: string; orgId?: string; planId: string; planPeriod: string }): CheckoutKey { - const { userId, orgId, planId, planPeriod } = options; - return `${userId}-${orgId || 'user'}-${planId}-${planPeriod}` as CheckoutKey; +function cacheKey(options: { + userId: string; + orgId?: string; + planId: string; + planPeriod: string; + seatsQuantity?: number; +}): CheckoutKey { + const { userId, orgId, planId, planPeriod, seatsQuantity } = options; + return `${userId}-${orgId || 'user'}-${planId}-${planPeriod}-${seatsQuantity}` as CheckoutKey; } /** @@ -26,7 +32,7 @@ const CheckoutSignalCache = new Map< * Create a checkout instance with the given options */ function createCheckoutInstance(clerk: Clerk, options: __experimental_CheckoutOptions): CheckoutSignalValue { - const { for: forOrganization, planId, planPeriod } = options; + const { for: forOrganization, planId, planPeriod, seatsQuantity } = options; if (clerk.user === null) { throw new Error('Clerk: User is not authenticated'); @@ -43,6 +49,7 @@ function createCheckoutInstance(clerk: Clerk, options: __experimental_CheckoutOp orgId: forOrganization === 'organization' ? clerk.organization?.id : undefined, planId, planPeriod, + seatsQuantity, }); const checkoutInstance = CheckoutSignalCache.get(checkoutKey); @@ -56,6 +63,7 @@ function createCheckoutInstance(clerk: Clerk, options: __experimental_CheckoutOp ...(forOrganization === 'organization' ? { orgId: clerk.organization?.id } : {}), planId, planPeriod, + seatsQuantity, }); CheckoutSignalCache.set(checkoutKey, { resource: checkout, signals }); diff --git a/packages/react/src/components/CheckoutButton.tsx b/packages/react/src/components/CheckoutButton.tsx index a3ef2b3108b..a0d060ee088 100644 --- a/packages/react/src/components/CheckoutButton.tsx +++ b/packages/react/src/components/CheckoutButton.tsx @@ -50,6 +50,7 @@ export const CheckoutButton = withClerk( const { planId, planPeriod, + seatsQuantity, for: _for, onSubscriptionComplete, newSubscriptionRedirectUrl, @@ -84,6 +85,7 @@ export const CheckoutButton = withClerk( return clerk.__internal_openCheckout({ planId, planPeriod, + seatsQuantity, for: _for, onSubscriptionComplete, newSubscriptionRedirectUrl, diff --git a/packages/react/src/components/__tests__/CheckoutButton.test.tsx b/packages/react/src/components/__tests__/CheckoutButton.test.tsx index fe8d7f68fb3..206880cd368 100644 --- a/packages/react/src/components/__tests__/CheckoutButton.test.tsx +++ b/packages/react/src/components/__tests__/CheckoutButton.test.tsx @@ -101,6 +101,7 @@ describe('CheckoutButton', () => { const props = { planId: 'test_plan', planPeriod: 'month' as const, + seatsQuantity: 7, onSubscriptionComplete: vi.fn(), newSubscriptionRedirectUrl: '/success', checkoutProps: { @@ -121,6 +122,7 @@ describe('CheckoutButton', () => { onSubscriptionComplete: props.onSubscriptionComplete, newSubscriptionRedirectUrl: props.newSubscriptionRedirectUrl, planPeriod: props.planPeriod, + seatsQuantity: props.seatsQuantity, }), ); }); diff --git a/packages/shared/src/errors/clerkApiError.ts b/packages/shared/src/errors/clerkApiError.ts index 4daf5fa329b..857d6d1535b 100644 --- a/packages/shared/src/errors/clerkApiError.ts +++ b/packages/shared/src/errors/clerkApiError.ts @@ -26,6 +26,8 @@ export class ClerkAPIError implements Cler zxcvbn: json.meta?.zxcvbn, plan: json.meta?.plan, isPlanUpgradePossible: json.meta?.is_plan_upgrade_possible, + seatQuantityToAdd: json.meta?.seat_quantity_to_add, + seatQuantity: json.meta?.seat_quantity, } as unknown as Meta, }; this.code = parsedError.code; diff --git a/packages/shared/src/errors/parseError.ts b/packages/shared/src/errors/parseError.ts index 4c2f8c98baa..3c05b3e24b3 100644 --- a/packages/shared/src/errors/parseError.ts +++ b/packages/shared/src/errors/parseError.ts @@ -39,6 +39,8 @@ export function errorToJSON(error: ClerkAPIError | null): ClerkAPIErrorJSON { zxcvbn: error?.meta?.zxcvbn, plan: error?.meta?.plan, is_plan_upgrade_possible: error?.meta?.isPlanUpgradePossible, + seat_quantity_to_add: error?.meta?.seatQuantityToAdd, + seat_quantity: error?.meta?.seatQuantity, }, }; } diff --git a/packages/shared/src/react/contexts.tsx b/packages/shared/src/react/contexts.tsx index 06dd7765dd1..5c522720f61 100644 --- a/packages/shared/src/react/contexts.tsx +++ b/packages/shared/src/react/contexts.tsx @@ -73,6 +73,7 @@ export type UseCheckoutOptions = { * The ID of the Subscription Plan to check out (e.g. `cplan_xxx`). */ planId: string; + seatsQuantity?: number; }; const [CheckoutContext, useCheckoutContext] = createContextAndHook('CheckoutContext'); diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index 1fd72e2f5fc..4f5fb41b8bc 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -17,7 +17,7 @@ type UseCheckoutParams = Parameters[0]; */ export const useCheckout = (options?: UseCheckoutParams): CheckoutSignalValue => { const contextOptions = useCheckoutContext(); - const { for: forOrganization, planId, planPeriod } = options || contextOptions; + const { for: forOrganization, planId, planPeriod, seatsQuantity } = options || contextOptions; const organization = useOrganizationBase(); const { isLoaded, user } = useUser(); const clerk = useClerkInstanceContext(); @@ -33,8 +33,8 @@ export const useCheckout = (options?: UseCheckoutParams): CheckoutSignalValue => } const signal = useCallback(() => { - return clerk.__experimental_checkout({ planId, planPeriod, for: forOrganization }); - }, [user?.id, organization?.id, planId, planPeriod, forOrganization]); + return clerk.__experimental_checkout({ planId, planPeriod, for: forOrganization, seatsQuantity }); + }, [user?.id, organization?.id, planId, planPeriod, forOrganization, seatsQuantity]); const subscribe = useCallback( (callback: () => void) => { diff --git a/packages/shared/src/types/billing.ts b/packages/shared/src/types/billing.ts index 786887fd2b6..c8c12e9cda6 100644 --- a/packages/shared/src/types/billing.ts +++ b/packages/shared/src/types/billing.ts @@ -140,6 +140,8 @@ export type GetPlansParams = ClerkPaginationParams<{ * The type of payer for the Plans. */ for?: ForPayerType; + org_id?: string; + min_seats?: number; }>; /** @@ -889,6 +891,7 @@ export type CreateCheckoutParams = WithOptionalOrgType<{ * The billing period for the Plan. */ planPeriod: BillingSubscriptionPlanPeriod; + seatsQuantity?: number; }>; /** diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index f4ab6a08140..1b160bb6e06 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -79,6 +79,7 @@ export type __experimental_CheckoutOptions = { for?: ForPayerType; planPeriod: BillingSubscriptionPlanPeriod; planId: string; + seatsQuantity?: number; }; export type CheckoutErrors = { @@ -2148,6 +2149,7 @@ export type __internal_CheckoutProps = { appearance?: ClerkAppearanceTheme; planId?: string; planPeriod?: BillingSubscriptionPlanPeriod; + seatsQuantity?: number; for?: ForPayerType; onSubscriptionComplete?: () => void; portalId?: string; @@ -2168,6 +2170,7 @@ export type __experimental_CheckoutButtonProps = { planId: string; planPeriod?: BillingSubscriptionPlanPeriod; for?: ForPayerType; + seatsQuantity?: number; onSubscriptionComplete?: () => void; checkoutProps?: { appearance?: ClerkAppearanceTheme; diff --git a/packages/shared/src/types/errors.ts b/packages/shared/src/types/errors.ts index ff8e17be7ac..9352a86b503 100644 --- a/packages/shared/src/types/errors.ts +++ b/packages/shared/src/types/errors.ts @@ -21,6 +21,8 @@ export interface ClerkAPIErrorJSON { name: string; }; is_plan_upgrade_possible?: boolean; + seat_quantity_to_add?: number; + seat_quantity?: number; }; } @@ -63,6 +65,8 @@ export interface ClerkAPIError { name: string; }; isPlanUpgradePossible?: boolean; + seatQuantityToAdd?: number; + seatQuantity?: number; }; } diff --git a/packages/ui/src/components/Checkout/CheckoutPage.tsx b/packages/ui/src/components/Checkout/CheckoutPage.tsx index 220b1f036bd..d407394ab1a 100644 --- a/packages/ui/src/components/Checkout/CheckoutPage.tsx +++ b/packages/ui/src/components/Checkout/CheckoutPage.tsx @@ -11,12 +11,12 @@ const Initiator = () => { useEffect(() => { void checkout.start(); - }, []); + }, [checkout]); return null; }; const Root = ({ children }: { children: React.ReactNode }) => { - const { planId, planPeriod, for: _for } = useCheckoutContext(); + const { planId, planPeriod, for: _for, seatsQuantity } = useCheckoutContext(); return ( { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion planPeriod! } + seatsQuantity={seatsQuantity} > {children} diff --git a/packages/ui/src/components/Checkout/__tests__/Checkout.test.tsx b/packages/ui/src/components/Checkout/__tests__/Checkout.test.tsx index 8414f0c9ea8..008fc6e609b 100644 --- a/packages/ui/src/components/Checkout/__tests__/Checkout.test.tsx +++ b/packages/ui/src/components/Checkout/__tests__/Checkout.test.tsx @@ -61,6 +61,39 @@ describe('Checkout', () => { }); }); + it('passes seatsQuantity to checkout initialization', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withBilling(); + }); + + fixtures.clerk.billing.startCheckout.mockResolvedValue({} as any); + + render( + {}} + > + + , + { wrapper }, + ); + + await waitFor(() => { + expect(fixtures.clerk.billing.startCheckout).toHaveBeenCalledWith( + expect.objectContaining({ + planId: 'plan_with_seats', + planPeriod: 'month', + seatsQuantity: 7, + }), + ); + }); + }); + it('renders drawer structure and localization correctly', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); diff --git a/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx b/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx index 3a8ecc68b8d..ceabc004d75 100644 --- a/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx +++ b/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx @@ -1,5 +1,5 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; -import { useOrganization } from '@clerk/shared/react'; +import { useClerk, useOrganization } from '@clerk/shared/react'; import type { ClerkAPIError } from '@clerk/shared/types'; import type { FormEvent } from 'react'; import { useEffect, useState } from 'react'; @@ -9,10 +9,11 @@ import { Form } from '@/ui/elements/Form'; import { FormButtonContainer } from '@/ui/elements/FormButtons'; import { TagInput } from '@/ui/elements/TagInput'; import { handleError } from '@/ui/utils/errorHandler'; +import { getClosestProfileScrollBoxFromElement } from '@/ui/utils/getClosestProfileScrollBox'; import { createListFormat } from '@/ui/utils/passwordUtils'; import { useFormControl } from '@/ui/utils/useFormControl'; -import { useEnvironment } from '../../contexts'; +import { useEnvironment, usePlansContext, useSubscription } from '../../contexts'; import { Flex } from '../../customizables'; import { useFetchRoles } from '../../hooks/useFetchRoles'; import type { LocalizationKey } from '../../localization'; @@ -31,12 +32,15 @@ type InviteMembersFormProps = { export const InviteMembersForm = (props: InviteMembersFormProps) => { const { onSuccess, onReset, resetButtonLabel } = props; + const clerk = useClerk(); const { organization, invitations } = useOrganization({ invitations: { pageSize: 10, keepPreviousData: true, }, }); + const { subscriptionItems } = useSubscription(); + const { handleSelectPlan } = usePlansContext(); const card = useCardState(); const { t, locale } = useLocalizations(); const [isValidUnsubmittedEmail, setIsValidUnsubmittedEmail] = useState(false); @@ -75,72 +79,112 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => { const canSubmit = (!!emailAddressField.value.length || isValidUnsubmittedEmail) && !!roleField.value; - const onSubmit = (e: FormEvent) => { + const onSubmit = async (e: FormEvent) => { e.preventDefault(); if (!canSubmit) { return; } const submittedData = new FormData(e.currentTarget); - return organization - .inviteMembers({ + const portalRoot = getClosestProfileScrollBoxFromElement(e.currentTarget); + try { + await organization.inviteMembers({ emailAddresses: emailAddressField.value.split(','), role: submittedData.get('role') as string, - }) - .then(async () => { - await invitations?.revalidate?.(); - return onSuccess?.(); - }) - .catch(err => { - if (!isClerkAPIResponseError(err)) { + }); + + await invitations?.revalidate?.(); + onSuccess?.(); + } catch (err) { + if (!isClerkAPIResponseError(err)) { + if (err instanceof Error) { handleError(err, [], card.setError); return; } - removeInvalidEmails(err.errors[0]); - - switch (err.errors?.[0]?.code) { - case 'duplicate_record': { - const unlocalizedEmailsList = err.errors[0].meta?.emailAddresses || []; - card.setError( - t( - localizationKeys('organizationProfile.invitePage.detailsTitle__inviteFailed', { - // Create a localized list of email addresses - email_addresses: createListFormat(unlocalizedEmailsList, locale), - }), - ), - ); - break; - } - case 'already_a_member_in_organization': { - /** - * Extracts email from the error message since it's not provided in the error response - */ - const longMessage = err.errors[0].longMessage ?? ''; - const email = longMessage.match(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/)?.[0]; - - handleError(err, [], err => - email - ? /** - * Fallbacks to original error message in case the email cannot be extracted - */ - card.setError( - t( - localizationKeys('unstable__errors.already_a_member_in_organization', { - email, - }), - ), - ) - : card.setError(err), - ); - - break; + throw err; + } + + removeInvalidEmails(err.errors[0]); + + switch (err.errors?.[0]?.code) { + case 'duplicate_record': { + const unlocalizedEmailsList = err.errors[0].meta?.emailAddresses || []; + card.setError( + t( + localizationKeys('organizationProfile.invitePage.detailsTitle__inviteFailed', { + // Create a localized list of email addresses + email_addresses: createListFormat(unlocalizedEmailsList, locale), + }), + ), + ); + break; + } + case 'already_a_member_in_organization': { + /** + * Extracts email from the error message since it's not provided in the error response + */ + const longMessage = err.errors[0].longMessage ?? ''; + const email = longMessage.match(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/)?.[0]; + + handleError(err, [], err => + email + ? /** + * Fallbacks to original error message in case the email cannot be extracted + */ + card.setError( + t( + localizationKeys('unstable__errors.already_a_member_in_organization', { + email, + }), + ), + ) + : card.setError(err), + ); + + break; + } + case 'insufficient_seats': { + console.log({ err }); + const { data: plans } = await clerk.billing.getPlans({ + for: 'organization', + org_id: organization.id, + min_seats: err.errors[0].meta?.seatQuantity, + }); + + if (plans.length === 0) { + console.error('No plans support the desired number of seats.'); } - default: { - handleError(err, [], card.setError); + + const activeSubscriptionItem = subscriptionItems.find(si => si.status === 'active'); + if (activeSubscriptionItem) { + const currentPlan = activeSubscriptionItem.plan; + const currentPlanSupportsDesiredSeatQuantity = plans.some(p => p.id === currentPlan.id); + if (currentPlanSupportsDesiredSeatQuantity) { + console.log('params', { + mode: 'modal', + plan: currentPlan, + planPeriod: activeSubscriptionItem.planPeriod, + seatsQuantity: err.errors[0].meta?.seatQuantity, + portalRoot, + }); + handleSelectPlan({ + mode: 'modal', + plan: currentPlan, + planPeriod: activeSubscriptionItem.planPeriod, + seatsQuantity: err.errors[0].meta?.seatQuantity, + portalRoot, + }); + } } + + break; } - }); + default: { + handleError(err, [], card.setError); + } + } + } }; const removeInvalidEmails = (err: ClerkAPIError) => { diff --git a/packages/ui/src/components/OrganizationProfile/MembersActions.tsx b/packages/ui/src/components/OrganizationProfile/MembersActions.tsx index bf7824ef1af..10e03cab5c6 100644 --- a/packages/ui/src/components/OrganizationProfile/MembersActions.tsx +++ b/packages/ui/src/components/OrganizationProfile/MembersActions.tsx @@ -2,8 +2,10 @@ 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 { isPlanWithPerSeatCosts } from '@/utils/billingPlanSeats'; import { useProtect } from '../../common'; +import { useSubscription } from '../../contexts'; import { Button, descriptors, Flex, localizationKeys } from '../../customizables'; import { Action } from '../../elements/Action'; import { InviteMembersScreen } from './InviteMembersScreen'; @@ -15,19 +17,24 @@ type MembersActionsRowProps = { export const MembersActionsRow = ({ actionSlot }: MembersActionsRowProps) => { const canManageMemberships = useProtect({ permission: 'org:sys_memberships:manage' }); const { organization } = useOrganization(); + const { subscriptionItems } = useSubscription(); const isBelowLimit = useMemo(() => { if (!organization) { return false; } + if (subscriptionItems.length > 0 && isPlanWithPerSeatCosts(subscriptionItems[0].plan)) { + return true; + } + // 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]); + }, [organization, subscriptionItems]); const inviteButton = (