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(); + }); +});