diff --git a/.changeset/good-ads-greet.md b/.changeset/good-ads-greet.md new file mode 100644 index 00000000000..06dfb32119a --- /dev/null +++ b/.changeset/good-ads-greet.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Add support for rendering per-seat costs in checkout diff --git a/.changeset/khaki-hairs-punch.md b/.changeset/khaki-hairs-punch.md new file mode 100644 index 00000000000..7b4e9fe5ebc --- /dev/null +++ b/.changeset/khaki-hairs-punch.md @@ -0,0 +1,8 @@ +--- +'@clerk/localizations': minor +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Add support for total due per period to checkout diff --git a/packages/clerk-js/src/utils/billing.ts b/packages/clerk-js/src/utils/billing.ts index 77b28782197..e994843361f 100644 --- a/packages/clerk-js/src/utils/billing.ts +++ b/packages/clerk-js/src/utils/billing.ts @@ -77,6 +77,9 @@ export const billingTotalsFromJSON = ; + totalDuePerPeriod: LocalizationValue; perMonth: LocalizationValue; }; }; diff --git a/packages/ui/src/components/Checkout/CheckoutForm.tsx b/packages/ui/src/components/Checkout/CheckoutForm.tsx index 2a6dc9c4122..2adca757fe4 100644 --- a/packages/ui/src/components/Checkout/CheckoutForm.tsx +++ b/packages/ui/src/components/Checkout/CheckoutForm.tsx @@ -9,7 +9,12 @@ import { LineItems } from '@/ui/elements/LineItems'; import { SegmentedControl } from '@/ui/elements/SegmentedControl'; import { Select, SelectButton, SelectOptionList } from '@/ui/elements/Select'; import { Tooltip } from '@/ui/elements/Tooltip'; -import { getSeatUnitPrice } from '@/ui/utils/billingPlanSeats'; +import { + getCheckoutSeatUnitTotal, + getIncludedSeatsUnitTotalTier, + getPaidSeatsUnitTotalTier, + getSeatUnitPrice, +} from '@/ui/utils/billingPlanSeats'; import { handleError } from '@/ui/utils/errorHandler'; import { DevOnly } from '../../common/DevOnly'; @@ -48,10 +53,21 @@ export const CheckoutForm = withCardStateProvider(() => { : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion plan.annualMonthlyFee!; - const descriptionElements = []; + const seatPerUnitTotal = getCheckoutSeatUnitTotal(totals); + const includedSeatsTier = getIncludedSeatsUnitTotalTier(seatPerUnitTotal); + const paidSeatsTier = getPaidSeatsUnitTotalTier(seatPerUnitTotal); + + const descriptionElements: Array> = []; if (planPeriod === 'annual') { descriptionElements.push(localizationKeys('billing.billedAnnually')); } + if (includedSeatsTier && includedSeatsTier.quantity !== null) { + descriptionElements.push( + localizationKeys('billing.pricingTable.seatCost.includedSeats', { + includedSeats: includedSeatsTier.quantity, + }), + ); + } const seatUnitPrice = getSeatUnitPrice(plan); if (seatUnitPrice && seatUnitPrice.tiers.length === 1 && seatUnitPrice.tiers[0].feePerBlock.amount === 0) { descriptionElements.push( @@ -91,6 +107,16 @@ export const CheckoutForm = withCardStateProvider(() => { suffix={localizationKeys('billing.checkout.perMonth')} /> + {paidSeatsTier && paidSeatsTier.quantity !== null && ( + + + + + )} { )} - {!!freeTrialEndsAt && !!plan.freeTrialDays && totals.totalDueAfterFreeTrial && ( + {!!freeTrialEndsAt && !!plan.freeTrialDays && totals.totalDueAfterFreeTrial ? ( { text={`${totals.totalDueAfterFreeTrial.currencySymbol}${totals.totalDueAfterFreeTrial.amountFormatted}`} /> + ) : ( + + + + )} diff --git a/packages/ui/src/elements/LineItems.tsx b/packages/ui/src/elements/LineItems.tsx index ab5173392ba..c6e35f6938f 100644 --- a/packages/ui/src/elements/LineItems.tsx +++ b/packages/ui/src/elements/LineItems.tsx @@ -112,7 +112,6 @@ const Title = React.forwardRef(({ title, descr ({ display: 'inline-flex', - alignItems: 'center', gap: t.space.$1, })} > diff --git a/packages/ui/src/utils/billingPlanSeats.ts b/packages/ui/src/utils/billingPlanSeats.ts index e5732c2d530..c7805ee8f2b 100644 --- a/packages/ui/src/utils/billingPlanSeats.ts +++ b/packages/ui/src/utils/billingPlanSeats.ts @@ -1,4 +1,10 @@ -import type { BillingPlanResource, BillingPlanUnitPrice, OrganizationResource } from '@clerk/shared/types'; +import type { + BillingPerUnitTotal, + BillingPerUnitTotalTier, + BillingPlanResource, + BillingPlanUnitPrice, + OrganizationResource, +} from '@clerk/shared/types'; /** * Given a plan, return the unit price for seats. @@ -17,6 +23,69 @@ export const getSeatUnitPrice = (plan: { unitPrices?: BillingPlanUnitPrice[] }): return null; }; +/** + * Similar to the above, given a checkout totals, return the unit price for seats. + */ +export const getCheckoutSeatUnitTotal = (checkout: { + perUnitTotals?: BillingPerUnitTotal[]; +}): BillingPerUnitTotal | null => { + if (!checkout.perUnitTotals?.length) { + return null; + } + + const seatUnitPrice = checkout.perUnitTotals.find(unitTotal => unitTotal.name === 'seats'); + + if (seatUnitPrice) { + return seatUnitPrice; + } + + return null; +}; + +/** + * Given a checkout unit total, return the unit total tier that represents per-seat costs. If no tier is found, return null. + */ +export const getPaidSeatsUnitTotalTier = (unitTotal: BillingPerUnitTotal | null): BillingPerUnitTotalTier | null => { + if (!unitTotal) { + return null; + } + + if (unitTotal.tiers.length === 1 && unitTotal.tiers[0].feePerBlock.amount > 0) { + return unitTotal.tiers[0]; + } + + if ( + unitTotal.tiers.length === 2 && + unitTotal.tiers[0].feePerBlock.amount === 0 && + unitTotal.tiers[1].feePerBlock.amount > 0 + ) { + return unitTotal.tiers[1]; + } + + return null; +}; + +/** + * Given a checkout unit total, return the unit total tier that represents included seats. If no tier is found, return null. + */ +export const getIncludedSeatsUnitTotalTier = ( + unitTotal: BillingPerUnitTotal | null, +): BillingPerUnitTotalTier | null => { + if (!unitTotal) { + return null; + } + + if ( + unitTotal.tiers.length === 2 && + unitTotal.tiers[0].feePerBlock.amount === 0 && + unitTotal.tiers[1].feePerBlock.amount > 0 + ) { + return unitTotal.tiers[0]; + } + + return null; +}; + /** * Given a plan, return the seat limit for the plan, or undefined if the plan does not have a seat limit. */