Skip to content

Commit c3a2e5f

Browse files
committed
feat(localizations,shared,ui): Change invite members CTA based on available seats
1 parent 086920b commit c3a2e5f

4 files changed

Lines changed: 61 additions & 3 deletions

File tree

packages/localizations/src/en-US.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,7 @@ export const enUS: LocalizationResource = {
535535
detailsTitle__inviteFailed:
536536
'The invitations could not be sent. There are already pending invitations for the following email addresses: {{email_addresses}}.',
537537
formButtonPrimary__continue: 'Send invitations',
538+
formButtonPrimary__purchaseSeats: 'Purchase additional seats',
538539
selectDropdown__role: 'Select role',
539540
subtitle: 'Enter or paste one or more email addresses, separated by spaces or commas.',
540541
successMessage: 'Invitations successfully sent',

packages/shared/src/types/localization.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1110,6 +1110,7 @@ export type __internal_LocalizationResource = {
11101110
successMessage: LocalizationValue;
11111111
detailsTitle__inviteFailed: LocalizationValue<'email_addresses'>;
11121112
formButtonPrimary__continue: LocalizationValue;
1113+
formButtonPrimary__purchaseSeats: LocalizationValue;
11131114
selectDropdown__role: LocalizationValue;
11141115
};
11151116
removeDomainPage: {

packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,18 @@ import { handleError } from '@/ui/utils/errorHandler';
1212
import { createListFormat } from '@/ui/utils/passwordUtils';
1313
import { useFormControl } from '@/ui/utils/useFormControl';
1414

15-
import { useEnvironment } from '../../contexts';
15+
import { useEnvironment, useSubscription } from '../../contexts';
1616
import { Flex } from '../../customizables';
1717
import { useFetchRoles } from '../../hooks/useFetchRoles';
1818
import type { LocalizationKey } from '../../localization';
1919
import { localizationKeys, useLocalizations } from '../../localization';
2020
import { mqu } from '../../styledSystem';
2121
import { RoleSelect } from './MemberListTable';
22+
import {
23+
getPaidSeatsUnitTier,
24+
getSeatUnitPrice,
25+
organizationAndInvitationsExceedsPurchasedSeats,
26+
} from '@/utils/billingPlanSeats';
2227

2328
const isEmail = (str: string) => /^\S+@\S+\.\S+$/.test(str);
2429

@@ -37,6 +42,8 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => {
3742
keepPreviousData: true,
3843
},
3944
});
45+
const { data: subscription } = useSubscription();
46+
const activeSubscriptionItem = subscription?.subscriptionItems.find(si => si.status === 'active');
4047
const card = useCardState();
4148
const { t, locale } = useLocalizations();
4249
const [isValidUnsubmittedEmail, setIsValidUnsubmittedEmail] = useState(false);
@@ -74,6 +81,14 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => {
7481
} = emailAddressField;
7582

7683
const canSubmit = (!!emailAddressField.value.length || isValidUnsubmittedEmail) && !!roleField.value;
84+
const emailAddresses = emailAddressField.value.split(',');
85+
86+
const seatUnitPrice = activeSubscriptionItem ? getSeatUnitPrice(activeSubscriptionItem.plan) : null;
87+
const paidSeatsTier = seatUnitPrice ? getPaidSeatsUnitTier(seatUnitPrice) : null;
88+
const isPerSeatCostPlan = !!paidSeatsTier;
89+
const mustPurchaseSeats =
90+
isPerSeatCostPlan &&
91+
organizationAndInvitationsExceedsPurchasedSeats(activeSubscriptionItem, organization, emailAddresses.length);
7792

7893
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
7994
e.preventDefault();
@@ -84,7 +99,7 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => {
8499
const submittedData = new FormData(e.currentTarget);
85100
return organization
86101
.inviteMembers({
87-
emailAddresses: emailAddressField.value.split(','),
102+
emailAddresses,
88103
role: submittedData.get('role') as string,
89104
})
90105
.then(async () => {
@@ -176,7 +191,11 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => {
176191
<Form.SubmitButton
177192
block={false}
178193
isDisabled={!canSubmit}
179-
localizationKey={localizationKeys('organizationProfile.invitePage.formButtonPrimary__continue')}
194+
localizationKey={
195+
isPerSeatCostPlan && mustPurchaseSeats
196+
? localizationKeys('organizationProfile.invitePage.formButtonPrimary__purchaseSeats')
197+
: localizationKeys('organizationProfile.invitePage.formButtonPrimary__continue')
198+
}
180199
/>
181200
<Form.ResetButton
182201
localizationKey={resetButtonLabel || localizationKeys('userProfile.formButtonReset')}

packages/ui/src/utils/billingPlanSeats.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type {
33
BillingPerUnitTotalTier,
44
BillingPlanResource,
55
BillingPlanUnitPrice,
6+
BillingPlanUnitPriceTier,
7+
BillingSubscriptionItemResource,
68
OrganizationResource,
79
} from '@clerk/shared/types';
810

@@ -86,6 +88,26 @@ export const getIncludedSeatsUnitTotalTier = (
8688
return null;
8789
};
8890

91+
export const getPaidSeatsUnitTier = (unitPrice: BillingPlanUnitPrice | null): BillingPlanUnitPriceTier | null => {
92+
if (!unitPrice) {
93+
return null;
94+
}
95+
96+
if (unitPrice.tiers.length === 1 && unitPrice.tiers[0].feePerBlock.amount > 0) {
97+
return unitPrice.tiers[0];
98+
}
99+
100+
if (
101+
unitPrice.tiers.length === 2 &&
102+
unitPrice.tiers[0].feePerBlock.amount === 0 &&
103+
unitPrice.tiers[1].feePerBlock.amount > 0
104+
) {
105+
return unitPrice.tiers[1];
106+
}
107+
108+
return null;
109+
};
110+
89111
/**
90112
* Given a plan, return the seat limit for the plan, or undefined if the plan does not have a seat limit.
91113
*/
@@ -114,3 +136,18 @@ export const organizationExceedsPlanSeatLimit = (
114136

115137
return organization.membersCount + organization.pendingInvitationsCount > seatLimit;
116138
};
139+
140+
export const organizationAndInvitationsExceedsPurchasedSeats = (
141+
subscriptionItem: BillingSubscriptionItemResource | undefined,
142+
organization: OrganizationResource,
143+
invitationsCount: number,
144+
): boolean => {
145+
if (!subscriptionItem || !subscriptionItem.seats || !subscriptionItem.seats.quantity) {
146+
return false;
147+
}
148+
149+
return (
150+
organization.membersCount + organization.pendingInvitationsCount + invitationsCount >
151+
subscriptionItem.seats.quantity
152+
);
153+
};

0 commit comments

Comments
 (0)