diff --git a/static/gsAdmin/views/billingPlans.spec.tsx b/static/gsAdmin/views/billingPlans.spec.tsx index a8a22e7e01ddfb..3e667b9587b4c2 100644 --- a/static/gsAdmin/views/billingPlans.spec.tsx +++ b/static/gsAdmin/views/billingPlans.spec.tsx @@ -95,7 +95,7 @@ describe('BillingPlans Component', () => { render(); // Verify that the main heading is rendered - expect(screen.getByText('Billing Plans')).toBeInTheDocument(); + expect(screen.getByText('Application Monitoring Billing Plans')).toBeInTheDocument(); // Wait for the plans data to be fetched and rendered await waitFor(() => { @@ -111,10 +111,7 @@ describe('BillingPlans Component', () => { expect(screen.getByText('Table of Contents')).toBeInTheDocument(); }); - expect(await screen.findByRole('link', {name: /AM9000\s+Plans/})).toBeInTheDocument(); - expect(screen.getByRole('link', {name: /Business/})).toBeInTheDocument(); - expect(screen.getByRole('link', {name: /Pricing/})).toBeInTheDocument(); - expect(screen.getByRole('link', {name: /Errors/})).toBeInTheDocument(); + expect(await screen.findByRole('link', {name: /Business/})).toBeInTheDocument(); }); it('renders pricing tables correctly', async () => { @@ -122,7 +119,7 @@ describe('BillingPlans Component', () => { // Wait for the pricing tables to be rendered await waitFor(() => { - expect(screen.getByText('Pricing:')).toBeInTheDocument(); + expect(screen.getByText('Pricing')).toBeInTheDocument(); }); // Check that pricing information is displayed @@ -136,21 +133,27 @@ describe('BillingPlans Component', () => { it('renders price tiers tables correctly', async () => { render(); - // Wait for the price tiers tables to be rendered + // Wait for the price tiers table to be rendered await waitFor(() => { - expect(screen.getByText('Errors for AM9000 Business')).toBeInTheDocument(); + expect(screen.getByText('Usage Price Tiers')).toBeInTheDocument(); }); - // Check that price tier information is displayed - expect(await screen.findByText('Tier')).toBeInTheDocument(); - expect(screen.getByText('Reserved PPE')).toBeInTheDocument(); - expect(screen.getByText('PAYG PPE')).toBeInTheDocument(); - expect(screen.getByText('1')).toBeInTheDocument(); - expect(screen.getByText('100,000')).toBeInTheDocument(); - expect(screen.getAllByText('$0.00')).toHaveLength(2); // Both reserved_ppe and od_ppe for tier 1 + // Column headers are always visible + expect(screen.getByRole('columnheader', {name: 'Tier'})).toBeInTheDocument(); + expect(screen.getByRole('columnheader', {name: 'Reserved PPE'})).toBeInTheDocument(); + expect(screen.getByRole('columnheader', {name: 'PAYG PPE'})).toBeInTheDocument(); + + // Rows are collapsed by default — expand them + const expandButton = screen + .getAllByRole('button') + .find(btn => btn.getAttribute('aria-expanded') !== null)!; + await userEvent.click(expandButton); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('100K')).toBeInTheDocument(); // formatVolumeCompact(100000) + // Tier 1 has monthly=0, annual=0, od_ppe=0, reserved_ppe=0 — all render as empty cells expect(screen.getByText('2')).toBeInTheDocument(); - expect(screen.getByText('1,000,000')).toBeInTheDocument(); + expect(screen.getByText('1M')).toBeInTheDocument(); // formatVolumeCompact(1000000) expect(screen.getByText('$1.60')).toBeInTheDocument(); // reserved_ppe expect(screen.getByText('$2.00')).toBeInTheDocument(); // od_ppe }); @@ -203,7 +206,7 @@ describe('BillingPlans Component', () => { expect(blobText).toContain( 'Monthly,Annual, , ,Tier,Volume (max),Monthly,Annual,Reserved PPE,PAYG PPE,' ); - expect(blobText).toContain('$89,$960, , ,1,100000,$0,$0,$0.00,$0.00,'); + expect(blobText).toContain('$89,$960, , ,1,100000,,,,,'); expect(blobText).toContain(' , , , ,2,1000000,$100,"$1,000",$1.60,$2.00,'); }); @@ -235,16 +238,6 @@ describe('BillingPlans Component', () => { }); const planNotLiveBadge = within(planHeader.parentElement!).getByText('NOT LIVE'); expect(planNotLiveBadge).toBeInTheDocument(); - - // Check that the 'NOT LIVE' badge is displayed next to the data category header - const dataCategoryHeader = screen.getByRole('heading', { - level: 5, - name: /Errors\s+for\s+AM9000\s+Business/i, - }); - const dataCategoryNotLiveBadge = within(dataCategoryHeader.parentElement!).getByText( - 'NOT LIVE' - ); - expect(dataCategoryNotLiveBadge).toBeInTheDocument(); }); it('displays "DISABLED" badge for data categories that are disabled', async () => { @@ -297,17 +290,15 @@ describe('BillingPlans Component', () => { expect(screen.getAllByText('AM9000 Plans').length).toBeGreaterThan(0); }); - // Check that the 'DISABLED' badge is displayed next to the data category - const dataCategoryHeader = screen.getByText('Errors for AM9000 Business'); - const disabledBadge = within(dataCategoryHeader.parentElement!).getByText('DISABLED'); - expect(disabledBadge).toBeInTheDocument(); + // Disabled categories are shown with "(DISABLED)" in the category label + expect(await screen.findByText('Errors (DISABLED)')).toBeInTheDocument(); }); it('renders without crashing and displays LIVE badges', async () => { render(); // Verify that the main heading is rendered - expect(screen.getByText('Billing Plans')).toBeInTheDocument(); + expect(screen.getByText('Application Monitoring Billing Plans')).toBeInTheDocument(); // Wait for the plans data to be fetched and rendered await waitFor(() => { @@ -320,10 +311,5 @@ describe('BillingPlans Component', () => { headerLiveBadges.forEach(badge => { expect(badge).toBeInTheDocument(); }); - - // Check that the LIVE badge is displayed for the data category - const dataCategoryHeader = screen.getByText('Errors for AM9000 Business'); - const liveBadge = within(dataCategoryHeader.parentElement!).getByText('LIVE'); - expect(liveBadge).toBeInTheDocument(); }); }); diff --git a/static/gsAdmin/views/billingPlans.tsx b/static/gsAdmin/views/billingPlans.tsx index 9e9a683840c761..16b850485f189c 100644 --- a/static/gsAdmin/views/billingPlans.tsx +++ b/static/gsAdmin/views/billingPlans.tsx @@ -1,12 +1,12 @@ +import {Fragment, useState, type ReactNode} from 'react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {useQuery} from '@tanstack/react-query'; -import {Badge} from '@sentry/scraps/badge'; import {Button} from '@sentry/scraps/button'; import {Panel} from 'sentry/components/panels/panel'; -import {IconDownload} from 'sentry/icons'; +import {IconCheckmark, IconChevron, IconClose, IconDownload} from 'sentry/icons'; import type {DataCategory} from 'sentry/types/core'; import {apiOptions} from 'sentry/utils/api/apiOptions'; @@ -14,9 +14,25 @@ import {ResultTable} from 'admin/components/resultTable'; import {formatCurrency} from 'getsentry/utils/formatCurrency'; import {displayUnitPrice} from 'getsentry/views/amCheckout/utils'; +interface SeatCosts { + ondemand?: number | null; + prepaid?: number | null; + standard?: number | null; +} + +interface CategoryInfo { + billed_category: string; + is_add_on: boolean; + name: string; + tally_type: string; + seat_costs?: SeatCosts; + unit_size?: number; +} + export interface BillingPlansResponse { data: Plans; - not_live: string[]; + categories?: Record; + not_live?: string[]; } type Plans = Record; @@ -27,6 +43,10 @@ interface PlanDetails { data_categories_disabled: DataCategory[]; price_tiers: Partial>; pricing: Record; + allow_reserved_budgets?: boolean; + has_custom_dynamic_sampling?: boolean; + has_ondemand_modes?: boolean; + id?: string; } interface Price { @@ -46,7 +66,6 @@ interface PriceTier { export function BillingPlans() { const { data: billingPlansResponse = { - not_live: [], data: {}, }, } = useQuery( @@ -139,10 +158,22 @@ export function BillingPlans() { row.push( tier.tier.toString(), // Tier tier.volume.toString(), // Volume (max) - formatCurrency(tier.monthly), // Monthly - formatCurrency(tier.annual), // Annual - displayUnitPrice({cents: tier.reserved_ppe, minDigits: 2, maxDigits: 10}), // Reserved PPE - displayUnitPrice({cents: tier.od_ppe, minDigits: 2, maxDigits: 10}), // PAYG PPE + tier.monthly === 0 ? '' : formatCurrency(tier.monthly), // Monthly + tier.annual === 0 ? '' : formatCurrency(tier.annual), // Annual + tier.reserved_ppe === 0 + ? '' + : displayUnitPrice({ + cents: tier.reserved_ppe, + minDigits: 2, + maxDigits: 10, + }), // Reserved PPE + tier.od_ppe === 0 + ? '' + : displayUnitPrice({ + cents: tier.od_ppe, + minDigits: 2, + maxDigits: 10, + }), // PAYG PPE ' ' // empty column ); } else { @@ -186,71 +217,112 @@ export function BillingPlans() { return ( -

Billing Plans

+

Application Monitoring Billing Plans

- {Object.entries(plans).map(([planTierId, planTier]) => ( - - ))} + {Object.entries(plans) + .sort(([a], [b]) => b.localeCompare(a)) + .map(([planTierId, planTier]) => ( + + ))}
); } +const PREFERRED_PLAN_ORDER = [ + 'developer', + 'team', + 'business', + 'enterprise_team', + 'enterprise_business', + 'enterprise_trial', + 'enterprise_team_ds', + 'enterprise_business_ds', + 'enterprise_trial_ds', +] as const; + +function getPlanColumnOrder(plans: Plans): string[] { + const allPlanNames = new Set(); + Object.values(plans).forEach(planTier => { + Object.keys(planTier).forEach(planName => allPlanNames.add(planName)); + }); + const preferred = PREFERRED_PLAN_ORDER.filter(p => allPlanNames.has(p)); + const preferredSet = new Set(PREFERRED_PLAN_ORDER); + const others = [...allPlanNames].filter(p => !preferredSet.has(p)).sort(); + return [...preferred, ...others]; +} + function TableOfContents({plans}: {plans: Plans}) { + const sortedTiers = Object.entries(plans).sort(([a], [b]) => b.localeCompare(a)); + const planColumnOrder = getPlanColumnOrder(plans); + return ( -

Table of Contents

-
    - {Object.entries(plans).map(([planTierId, planTier]) => { - const planTierIdFormatted = formatPlanTierId(planTierId); - return ( -
  • - {planTierIdFormatted} Plans -
      - {Object.entries(planTier).map(([planName, planDetails]) => { - const planNameFormatted = formatPlanName(planName); - const planTypeId = `${planTierIdFormatted}-${planNameFormatted}`; - const pricingId = `${planTierIdFormatted}-${planNameFormatted}-pricing`; - return ( -
    • - {planNameFormatted} -
        -
      • - Pricing -
          - {Object.entries(planDetails.price_tiers).map( - ([dataCategory]) => { - const dataCategoryFormatted = formatDataCategory( - dataCategory as DataCategory - ); - const dataCategoryId = `${planTypeId}-${dataCategoryFormatted}`; - return ( -
        • - - {dataCategoryFormatted} - -
        • - ); - } - )} -
        -
      • -
      -
    • - ); - })} -
    -
  • - ); - })} -
+

Table of Contents

+ + + + + + {planColumnOrder.map(planName => ( + {formatPlanName(planName, true)} + ))} + + + + {sortedTiers.length === 0 ? ( + + No plans. + + ) : ( + sortedTiers.map(([planTierId, planTier]) => { + const planTierIdFormatted = formatPlanTierId(planTierId); + return ( + + {planTierIdFormatted} + {planColumnOrder.map(planName => { + const planDetails = planTier[planName]; + return ( + + {planDetails ? ( + + + {formatPlanName(planName, true)} + + {planDetails.id && ( + + {planDetails.id} + + )} + + ) : ( + '—' + )} + + ); + })} + + ); + }) + )} + + +
); } @@ -258,24 +330,35 @@ function TableOfContents({plans}: {plans: Plans}) { function PlanTierSection({ planTierId, planTier, + categories, notLive, }: { - notLive: string[]; planTier: PlanTier; planTierId: string; + categories?: Record; + notLive?: string[]; }) { const planTierIdFormatted = formatPlanTierId(planTierId); + const isNotLive = notLive?.includes(planTierId) ?? false; return ( -
-

{planTierIdFormatted} Plans

+
+

+ {planTierIdFormatted} Plans +

{Object.entries(planTier).map(([planName, planDetails]) => ( ))}
@@ -286,49 +369,115 @@ function PlanDetailsSection({ planTierIdFormatted, planName, planDetails, - notLive, + categories, + isNotLive, }: { planDetails: PlanDetails; planName: string; planTierIdFormatted: string; - notLive?: boolean; + categories?: Record; + isNotLive?: boolean; }) { const theme = useTheme(); const planNameFormatted = formatPlanName(planName); - const planTypeId = `${planTierIdFormatted}-${planNameFormatted}`; - const pricingId = `${planTierIdFormatted}-${planNameFormatted}-pricing`; return (
-

+

{planTierIdFormatted} {planNameFormatted} Plan + {planDetails.id ? ` (${planDetails.id})` : null}

- - {notLive ? 'NOT LIVE' : 'LIVE'} - + {isNotLive ? ( + + NOT LIVE + + ) : ( + + LIVE + + )}
+ {/* Plan Features — only when backend sends a boolean for has_custom_dynamic_sampling */} + {typeof planDetails.has_custom_dynamic_sampling === 'boolean' ? ( + +

Plan Features

+ + + + + Has Custom Dynamic Sampling + Has Ondemand Modes + Allow Reserved Budgets + + + + + + {planDetails.has_custom_dynamic_sampling ? ( + + ) : ( + + )} + + + {planDetails.has_ondemand_modes ? ( + + ) : ( + + )} + + + {planDetails.allow_reserved_budgets ? ( + + ) : ( + + )} + + + + + +
+ ) : null} + {/* Pricing Table */} -

Pricing:

+

Pricing

- {/* Price Tiers Tables */} - {( - Object.entries(planDetails.price_tiers) as Array<[DataCategory, PriceTier[]]> - ).map(([dataCategory, tiers]) => ( - - ))} + {/* Price Tiers (single merged table) */} +
); } @@ -358,74 +507,573 @@ function PricingTable({pricing}: {pricing: Record}) { ); } -function PriceTiersTable({ +interface TierGroup { + bands: PriceTier[]; + categoryLabel: string; + dataCategory: DataCategory; + dataCategoryFormatted: string; + dataCategoryId: string; + disabled: boolean; + groupKey: string; + tierNumber: number; + categoryCode?: string; + tallyType?: string; + unitSize?: number; +} + +function getCategoryInfo( + categories: Record | undefined, + dataCategory: string, + dataCategoryFormatted: string +): {categoryCode?: string; seatCosts?: SeatCosts; tallyType?: string; unitSize?: number} { + if (!categories) return {}; + const entry = + categories[dataCategory] ?? + categories[dataCategoryFormatted] ?? + categories[dataCategoryFormatted.toLowerCase()]; + if (!entry) return {}; + if (typeof entry === 'string') { + return {categoryCode: entry.toLowerCase()}; + } + return { + categoryCode: entry.billed_category?.toLowerCase(), + seatCosts: entry.seat_costs, + tallyType: entry.tally_type, + unitSize: entry.unit_size, + }; +} + +function shouldShowCategoryCode(categoryLabel: string, categoryCode?: string): boolean { + if (!categoryCode) return false; + const baseLabel = categoryLabel.replace(/\s*\(DISABLED\)\s*$/i, '').trim(); + const labelWithoutPlural = baseLabel.toLowerCase().replace(/s$/, ''); + return labelWithoutPlural !== categoryCode; +} + +function MergedPriceTiersTable({ planTierIdFormatted, - planNameFormatted, - dataCategory, - tiers, - notLive, - data_categories_disabled, + planName, + planDetails, + categories, }: { - dataCategory: DataCategory; - data_categories_disabled: DataCategory[]; - planNameFormatted: string; + planDetails: PlanDetails; + planName: string; planTierIdFormatted: string; - tiers: PriceTier[]; - notLive?: boolean; + categories?: Record; }) { - const theme = useTheme(); - const dataCategoryFormatted = formatDataCategory(dataCategory); - const dataCategoryId = `${planTierIdFormatted}-${planNameFormatted}-${dataCategoryFormatted}`; + const [expandedKeys, setExpandedKeys] = useState>(new Set()); - const disabled = data_categories_disabled.includes(dataCategory); + const entries = ( + Object.entries(planDetails.price_tiers) as Array<[DataCategory, PriceTier[]]> + ) + .filter(([, tiers]) => tiers?.length) + .sort(([a], [b]) => formatDataCategory(a).localeCompare(formatDataCategory(b))); - const badgeText = notLive ? 'NOT LIVE' : disabled ? 'DISABLED' : 'LIVE'; - const badgeType = notLive || disabled ? 'warning' : 'new'; + const groups: TierGroup[] = entries.flatMap(([dataCategory, tiers]) => { + const dataCategoryFormatted = formatDataCategory(dataCategory); + const dataCategoryId = `${planTierIdFormatted}-${planName}-${dataCategory}`; + const disabled = planDetails.data_categories_disabled.includes(dataCategory); + const categoryLabel = disabled + ? `${dataCategoryFormatted} (DISABLED)` + : dataCategoryFormatted; + const {categoryCode, tallyType, unitSize} = getCategoryInfo( + categories, + dataCategory, + dataCategoryFormatted + ); - return ( -
-
-
- {dataCategoryFormatted} for {planTierIdFormatted} {planNameFormatted} -
- {badgeText} -
+ const byTier = (tiers ?? []).reduce>((acc, t) => { + const list = acc.get(t.tier) ?? []; + list.push(t); + acc.set(t.tier, list); + return acc; + }, new Map()); + + const isVolumeConstant = (v: number) => v === -1 || v === -2; + + const result: TierGroup[] = []; + for (const [tierNumber, bands] of Array.from(byTier.entries()).sort( + ([a], [b]) => a - b + )) { + const constantBands = bands.filter(t => isVolumeConstant(t.volume)); + const scalarBands = bands.filter(t => !isVolumeConstant(t.volume)); + + for (const band of constantBands) { + result.push({ + dataCategory, + tierNumber, + bands: [band], + groupKey: `${dataCategory}-${tierNumber}-vol${band.volume}`, + categoryCode, + tallyType, + unitSize, + dataCategoryFormatted, + dataCategoryId, + categoryLabel, + disabled, + }); + } + if (scalarBands.length > 0) { + result.push({ + dataCategory, + tierNumber, + bands: scalarBands, + groupKey: `${dataCategory}-${tierNumber}`, + categoryCode, + tallyType, + unitSize, + dataCategoryFormatted, + dataCategoryId, + categoryLabel, + disabled, + }); + } + } + return result; + }); + + const usageGroups = groups.filter( + g => !g.tallyType || g.tallyType.toUpperCase() === 'USAGE' + ); + + const seatPricingRows = ( + Object.entries(planDetails.price_tiers) as Array<[DataCategory, PriceTier[]]> + ) + .filter(([, tiers]) => tiers?.length) + .map(([dataCategory]) => { + const dataCategoryFormatted = formatDataCategory(dataCategory); + const disabled = planDetails.data_categories_disabled.includes(dataCategory); + const categoryLabel = disabled + ? `${dataCategoryFormatted} (DISABLED)` + : dataCategoryFormatted; + const info = getCategoryInfo(categories, dataCategory, dataCategoryFormatted); + if (info.tallyType?.toUpperCase() !== 'SEAT' || !info.seatCosts) return null; + return { + categoryLabel, + categoryCode: info.categoryCode, + seatCosts: info.seatCosts, + disabled, + }; + }) + .filter((row): row is NonNullable => row !== null) + .sort((a, b) => a.categoryLabel.localeCompare(b.categoryLabel)); + + const toggleExpanded = (key: string) => { + setExpandedKeys(prev => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const renderBandCells = (tier: PriceTier, unitSize?: number) => ( + + {renderVolume(tier.volume, unitSize)} + + {tier.monthly === 0 ? '' : formatCurrency(tier.monthly)} + + + {tier.annual === 0 ? '' : formatCurrency(tier.annual)} + + + {tier.reserved_ppe === 0 + ? '' + : displayUnitPrice({ + cents: tier.reserved_ppe, + minDigits: 2, + maxDigits: 10, + })} + + + {tier.od_ppe === 0 + ? '' + : displayUnitPrice({ + cents: tier.od_ppe, + minDigits: 2, + maxDigits: 10, + })} + + + ); + + const renderTiersTable = (tableGroups: TierGroup[]) => { + const categoryGroups = (() => { + const byCategory = new Map(); + for (const group of tableGroups) { + const list = byCategory.get(group.dataCategory) ?? []; + list.push(group); + byCategory.set(group.dataCategory, list); + } + return Array.from(byCategory.entries()).sort(([a], [b]) => a.localeCompare(b)); + })(); + + return ( + + Data Category Tier - Volume - Monthly - Annual - Reserved PPE - PAYG PPE + Volume (max) + Monthly + Annual + Reserved PPE + PAYG PPE - {tiers.map((tier, index) => ( - - {tier.tier} - {Number(tier.volume).toLocaleString('en-US')} - {formatCurrency(tier.monthly)} - {formatCurrency(tier.annual)} - - {displayUnitPrice({ - cents: tier.reserved_ppe, - minDigits: 2, - maxDigits: 10, + {categoryGroups.map(([dataCategory, categoryTierGroups], categoryIndex) => { + const firstGroup = categoryTierGroups[0]; + const categoryBgStyle = + categoryIndex % 2 === 0 + ? {backgroundColor: '#fafbfc'} + : {backgroundColor: '#f4f5f8'}; + if (!firstGroup) return null; + const {categoryLabel, dataCategoryId} = firstGroup; + const expandKey = dataCategory; + const isExpanded = expandedKeys.has(expandKey); + const totalRows = categoryTierGroups.reduce( + (sum, g) => sum + g.bands.length, + 0 + ); + const hasSingleTierAndBand = + categoryTierGroups.length === 1 && + categoryTierGroups[0]!.bands.length === 1; + + if (hasSingleTierAndBand) { + const group = categoryTierGroups[0]!; + const tier = group.bands[0]; + if (!tier) return null; + return ( + + + + {categoryLabel} + {shouldShowCategoryCode(categoryLabel, group.categoryCode) && ( + + {group.categoryCode} + + )} + + {group.tierNumber} + {renderBandCells(tier, group.unitSize)} + + ); + } + + if (!isExpanded) { + return ( + + + + + + {categoryLabel} + {shouldShowCategoryCode(categoryLabel, firstGroup.categoryCode) && ( + + {firstGroup.categoryCode} + + )} + + + {totalRows} row{totalRows === 1 ? '' : 's'} — click to expand + + + ); + } + + return ( + + {categoryTierGroups.map((group, groupIndex) => { + const {bands, tierNumber, groupKey} = group; + const isSingleBand = bands.length === 1; + + if (isSingleBand) { + const tier = bands[0]; + if (!tier) return null; + return ( + + + {groupIndex === 0 ? ( + + ) : null} + + + {groupIndex === 0 ? ( + + {categoryLabel} + {shouldShowCategoryCode( + categoryLabel, + group.categoryCode + ) && ( + + {group.categoryCode} + + )} + + ) : ( + '' + )} + + {tierNumber} + {renderBandCells(tier, group.unitSize)} + + ); + } + + const [first, ...rest] = bands; + if (!first) return null; + return ( + + + + {groupIndex === 0 ? ( + + ) : null} + + + {groupIndex === 0 ? ( + + {categoryLabel} + {shouldShowCategoryCode( + categoryLabel, + group.categoryCode + ) && ( + + {group.categoryCode} + + )} + + ) : ( + '' + )} + + {tierNumber} + {renderBandCells(first, group.unitSize)} + + {rest.map((tier, index) => ( + + + + + {renderBandCells(tier, group.unitSize)} + + ))} + + ); })} - - - {displayUnitPrice({cents: tier.od_ppe, minDigits: 2, maxDigits: 10})} - - - ))} + + ); + })} + ); + }; + + return ( +
+ {usageGroups.length > 0 && ( + +
+

Usage Price Tiers

+
+ {renderTiersTable(usageGroups)} +
+ )} + {seatPricingRows.length > 0 && ( + +
+

Seat Pricing

+
+ + + + + Data Category + Standard + Reserved + PAYG + + + + {seatPricingRows.map(row => ( + + + {row.categoryLabel} + {shouldShowCategoryCode(row.categoryLabel, row.categoryCode) && ( + + {row.categoryCode} + + )} + + + {typeof row.seatCosts.standard === 'number' + ? displayUnitPrice({ + cents: row.seatCosts.standard, + minDigits: 2, + maxDigits: 10, + }) + : '—'} + + + {typeof row.seatCosts.prepaid === 'number' + ? displayUnitPrice({ + cents: row.seatCosts.prepaid, + minDigits: 2, + maxDigits: 10, + }) + : '—'} + + + {typeof row.seatCosts.ondemand === 'number' + ? displayUnitPrice({ + cents: row.seatCosts.ondemand, + minDigits: 2, + maxDigits: 10, + }) + : '—'} + + + ))} + + + +
+ )}
); } @@ -435,7 +1083,20 @@ const BillingPlansContainer = styled('div')` `; const StyledResultTable = styled(ResultTable)` - margin-bottom: ${p => p.theme.space.xl}; + margin-bottom: ${p => p.theme.space.md}; + thead th { + background-color: #f0f0ff; + padding: 12px 2px; + } + td { + padding: 12px 2px; + vertical-align: middle; + } + td code { + padding: 0.45em 0 0 0; + font-size: 12px; + background: #f6f6f6; + } `; const TOCContainer = styled('nav')` @@ -456,11 +1117,150 @@ const TOCContainer = styled('nav')` } `; +const badgeStyle = { + width: '100%', + textAlign: 'center' as const, + padding: '4px 8px', + borderRadius: 4, + fontSize: 12, + fontWeight: 600, +}; + +function ReservedVolumeBadge() { + return ( +
+ RESERVED +
+ ); +} + +function UnlimitedVolumeBadge() { + return ( +
+ UNLIMITED +
+ ); +} + +// Mirrors Python constants for unit_size (TERABYTE, GIGABYTE, etc.) +const UNIT_SIZE_CONSTANTS: ReadonlyArray<[number, string]> = [ + [10 ** 12, 'TB'], + [10 ** 9, 'GB'], + [10 ** 6, 'MB'], + [10 ** 3, 'KB'], +]; + +function formatVolume(volume: number): string { + const n = Number(volume); + if (n === -2) return 'RESERVED_BUDGET_QUOTA'; + if (n === -1) return 'UNLIMITED_ONDEMAND'; + if (n === 0) return '0'; + if (Math.abs(n) < 0.0001 && Math.abs(n) > 0) { + return n.toFixed(15).replace(/\.?0+$/, ''); + } + return n.toLocaleString('en-US', {maximumFractionDigits: 10}); +} + +function getUnitSizeLabel(unitSize: number | undefined): string | undefined { + if (unitSize === undefined) return undefined; + for (const [constant, name] of UNIT_SIZE_CONSTANTS) { + if (unitSize === constant) return name; + } + return undefined; +} + +/** Format large volumes as K/M/B when no unit size (e.g. 1,000 → 1K, 1,000,000 → 1M) */ +function formatVolumeCompact(volume: number): string { + if (volume >= 1_000_000_000) { + const b = volume / 1_000_000_000; + const s = + b % 1 === 0 + ? String(Math.round(b)) + : b.toLocaleString('en-US', {maximumFractionDigits: 2, minimumFractionDigits: 0}); + return `${s}B`; + } + if (volume >= 1_000_000) { + const m = volume / 1_000_000; + const s = + m % 1 === 0 + ? String(Math.round(m)) + : m.toLocaleString('en-US', {maximumFractionDigits: 2, minimumFractionDigits: 0}); + return `${s}M`; + } + if (volume >= 1_000) { + const k = volume / 1_000; + const s = + k % 1 === 0 + ? String(Math.round(k)) + : k.toLocaleString('en-US', {maximumFractionDigits: 2, minimumFractionDigits: 0}); + return `${s}K`; + } + return formatVolume(volume); +} + +/** Convert volume+unitSize to largest appropriate unit (e.g. 1000 gb → 1 tb) */ +function toLargestUnit( + volume: number, + unitSize: number | undefined +): {unit: string; value: number} | null { + if (volume === 0 || unitSize === undefined) return null; + const unitLabel = getUnitSizeLabel(unitSize); + if (!unitLabel) return null; + const total = volume * unitSize; + for (const [constant, name] of UNIT_SIZE_CONSTANTS) { + if (total >= constant && total % constant === 0) { + const value = total / constant; + return {value, unit: name}; + } + } + return null; +} + +function renderVolume(volume: number, unitSize?: number): ReactNode { + const formatted = formatVolume(volume); + if (formatted === 'RESERVED_BUDGET_QUOTA') return ; + if (formatted === 'UNLIMITED_ONDEMAND') return ; + if (volume === 0) return formatted; + const converted = toLargestUnit(volume, unitSize); + if (converted) { + const displayValue = formatVolume(converted.value); + return `${displayValue}${converted.unit}`; + } + const unitLabel = getUnitSizeLabel(unitSize); + if (unitLabel) return `${formatted}${unitLabel}`; + return formatVolumeCompact(volume); +} + function formatPlanTierId(planTierId: string): string { return planTierId.toUpperCase(); } -function formatPlanName(planType: string): string { +/** Fragment id when `planDetails.id` is missing; uses API plan key (no spaces) so anchors stay valid HTML. */ +function planFallbackAnchorId(planTierIdFormatted: string, planNameKey: string): string { + return `${planTierIdFormatted}-${planNameKey}`; +} + +function formatPlanName(planType: string, shortenEnterprise = false): string { + if (planType.startsWith('enterprise_')) { + const suffix = planType.slice('enterprise_'.length); + const parts = suffix + .split('_') + .map(part => (part.length <= 2 ? part.toUpperCase() : capitalizeWords(part))); + const prefix = shortenEnterprise ? 'Ent ' : 'Enterprise '; + return prefix + parts.join(' '); + } return planType.charAt(0).toUpperCase() + planType.slice(1); }