diff --git a/static/gsApp/components/productTrial/productTrialTag.tsx b/static/gsApp/components/productTrial/productTrialTag.tsx index a105ddce8bcb81..4b48b683f85b5b 100644 --- a/static/gsApp/components/productTrial/productTrialTag.tsx +++ b/static/gsApp/components/productTrial/productTrialTag.tsx @@ -25,21 +25,21 @@ function ProductTrialTag({trial, type, showTrialEnded = false}: ProductTrialTagP return ( } type={type ?? 'default'}> - {t('Trial Ended')} + {t('Trial ended')} ); } if (!trial.isStarted) { return ( - } type={type ?? 'info'}> - {t('Trial Available')} + } type={type ?? 'promotion'}> + {t('Trial available')} ); } const daysLeft = -1 * getDaysSinceDate(trial.endDate ?? ''); - const tagType = type ?? (daysLeft <= 7 ? 'promotion' : 'highlight'); + const tagType = type ?? (daysLeft <= 7 ? 'warning' : 'highlight'); return ( } type={tagType}> diff --git a/static/gsApp/components/subscriptionSettingsLayout.tsx b/static/gsApp/components/subscriptionSettingsLayout.tsx index e38497e2be4901..f231cba9e7b51f 100644 --- a/static/gsApp/components/subscriptionSettingsLayout.tsx +++ b/static/gsApp/components/subscriptionSettingsLayout.tsx @@ -49,7 +49,6 @@ function SubscriptionSettingsLayout(props: Props) { - {children} diff --git a/static/gsApp/hooks/useCurrentBillingHistory.tsx b/static/gsApp/hooks/useCurrentBillingHistory.tsx new file mode 100644 index 00000000000000..bff97880d2b65e --- /dev/null +++ b/static/gsApp/hooks/useCurrentBillingHistory.tsx @@ -0,0 +1,25 @@ +import {useMemo} from 'react'; + +import {useApiQuery} from 'sentry/utils/queryClient'; +import useOrganization from 'sentry/utils/useOrganization'; + +import type {BillingHistory} from 'getsentry/types'; + +export function useCurrentBillingHistory() { + const organization = useOrganization(); + + const { + data: histories, + isPending, + isError, + } = useApiQuery([`/customers/${organization.slug}/history/`], { + staleTime: 0, // TODO(billing): Create an endpoint that returns the current history + }); + + const currentHistory: BillingHistory | null = useMemo(() => { + if (!histories) return null; + return histories.find((history: BillingHistory) => history.isCurrent) ?? null; + }, [histories]); + + return {currentHistory, isPending, isError}; +} diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx index 314651084b8206..ff1e572a843b7c 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -726,6 +726,18 @@ export function partnerPlanEndingModalIsDismissed( } } +export function getPercentage(quantity: number, total: number | null) { + if (typeof total === 'number' && total > 0) { + return (Math.min(quantity, total) / total) * 100; + } + return 0; +} + +export function displayPercentage(quantity: number, total: number | null) { + const percentage = getPercentage(quantity, total); + return percentage.toFixed(0) + '%'; +} + /** * Returns true if some billing details are set. */ diff --git a/static/gsApp/views/subscriptionPage/overview.spec.tsx b/static/gsApp/views/subscriptionPage/overview.spec.tsx index cddf584a470ec7..114ae463e0f266 100644 --- a/static/gsApp/views/subscriptionPage/overview.spec.tsx +++ b/static/gsApp/views/subscriptionPage/overview.spec.tsx @@ -88,6 +88,11 @@ describe('Subscription > Overview', () => { method: 'GET', }); + MockApiClient.addMockResponse({ + url: `/customers/${organization.slug}/history/`, + method: 'GET', + }); + SubscriptionStore.set(organization.slug, {}); }); diff --git a/static/gsApp/views/subscriptionPage/overview.tsx b/static/gsApp/views/subscriptionPage/overview.tsx index 15ab311b113233..85ea883c5b837b 100644 --- a/static/gsApp/views/subscriptionPage/overview.tsx +++ b/static/gsApp/views/subscriptionPage/overview.tsx @@ -34,6 +34,7 @@ import withPromotions from 'getsentry/utils/withPromotions'; import ContactBillingMembers from 'getsentry/views/contactBillingMembers'; import {openOnDemandBudgetEditModal} from 'getsentry/views/onDemandBudgets/editOnDemandButton'; import SubscriptionPageContainer from 'getsentry/views/subscriptionPage/components/subscriptionPageContainer'; +import UsageOverview from 'getsentry/views/subscriptionPage/usageOverview'; import openPerformanceQuotaCreditsPromoModal from './promotions/performanceQuotaCreditsPromo'; import openPerformanceReservedTransactionsDiscountModal from './promotions/performanceReservedTransactionsPromo'; @@ -60,8 +61,8 @@ type Props = { function Overview({location, subscription, promotionData}: Props) { const api = useApi(); const organization = useOrganization(); - const navigate = useNavigate(); const isNewBillingUI = hasNewBillingUI(organization); + const navigate = useNavigate(); const displayMode = ['cost', 'usage'].includes(location.query.displayMode as string) ? (location.query.displayMode as 'cost' | 'usage') @@ -335,14 +336,20 @@ function Overview({location, subscription, promotionData}: Props) { - - {renderUsageChart(usageData)} - {renderUsageCards(usageData)} - + {isNewBillingUI ? ( + + ) : ( + + + {renderUsageChart(usageData)} + {renderUsageCards(usageData)} + + + )} ); @@ -353,8 +360,14 @@ function Overview({location, subscription, promotionData}: Props) { - {renderUsageChart(usageData)} - {renderUsageCards(usageData)} + {isNewBillingUI ? ( + + ) : ( + + {renderUsageChart(usageData)} + {renderUsageCards(usageData)} + + )} ); diff --git a/static/gsApp/views/subscriptionPage/usageOverview.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview.spec.tsx new file mode 100644 index 00000000000000..5d02908948dc8d --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview.spec.tsx @@ -0,0 +1,119 @@ +import moment from 'moment-timezone'; +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {BillingHistoryFixture} from 'getsentry-test/fixtures/billingHistory'; +import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; +import {render, screen} from 'sentry-test/reactTestingLibrary'; +import {resetMockDate, setMockDate} from 'sentry-test/utils'; + +import {DataCategory} from 'sentry/types/core'; + +import SubscriptionStore from 'getsentry/stores/subscriptionStore'; +import UsageOverview from 'getsentry/views/subscriptionPage/usageOverview'; + +describe('UsageOverview', () => { + const organization = OrganizationFixture(); + const subscription = SubscriptionFixture({organization, plan: 'am3_business'}); + + beforeEach(() => { + setMockDate(new Date('2021-05-07')); + MockApiClient.clearMockResponses(); + organization.access = ['org:billing']; + SubscriptionStore.set(organization.slug, subscription); + MockApiClient.addMockResponse({ + url: `/customers/${organization.slug}/history/`, + method: 'GET', + body: BillingHistoryFixture(), + }); + }); + + afterEach(() => { + resetMockDate(); + }); + + it('renders columns and buttons for billing users', () => { + render(); + expect(screen.getByRole('columnheader', {name: 'Product'})).toBeInTheDocument(); + expect(screen.getByRole('columnheader', {name: 'Current usage'})).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', {name: 'Reserved usage'}) + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', {name: 'Reserved spend'}) + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', {name: 'Pay-as-you-go spend'}) + ).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'View usage history'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Download as CSV'})).toBeInTheDocument(); + }); + + it('renders columns for non-billing users', () => { + organization.access = []; + render(); + expect(screen.getByRole('columnheader', {name: 'Product'})).toBeInTheDocument(); + expect(screen.getByRole('columnheader', {name: 'Current usage'})).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', {name: 'Reserved usage'}) + ).toBeInTheDocument(); + expect(screen.queryByText('Reserved spend')).not.toBeInTheDocument(); + expect(screen.queryByText('Pay-as-you-go spend')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', {name: 'View usage history'}) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', {name: 'Download as CSV'}) + ).not.toBeInTheDocument(); + }); + + it('renders table based on subscription state', () => { + subscription.onDemandMaxSpend = 100_00; + subscription.productTrials = [ + { + category: DataCategory.PROFILE_DURATION, + isStarted: false, + reasonCode: 1001, + startDate: undefined, + endDate: moment().utc().add(20, 'years').format(), + }, + { + category: DataCategory.REPLAYS, + isStarted: true, + reasonCode: 1001, + startDate: moment().utc().subtract(10, 'days').format(), + endDate: moment().utc().add(20, 'days').format(), + }, + ]; + subscription.categories.errors = { + ...subscription.categories.errors!, + free: 1000, + usage: 6000, + onDemandSpendUsed: 10_00, + }; + subscription.categories.spans = { + ...subscription.categories.spans!, + reserved: 20_000_000, + }; + + render(); + + // Continuous profile hours product trial available + expect( + screen.getByRole('button', { + name: 'Start 14 day free Continuous Profile Hours trial', + }) + ).toBeInTheDocument(); + expect(screen.getByText('Trial available')).toBeInTheDocument(); + + // Replays product trial active + expect(screen.getByText('20 days left')).toBeInTheDocument(); + + // Errors usage and gifted units + expect(screen.getByText('6,000 / 51,000')).toBeInTheDocument(); + expect(screen.getByText('$10.00')).toBeInTheDocument(); + + // Reserved spans above platform volume + expect(screen.getByText('0 / 20,000,000')).toBeInTheDocument(); + expect(screen.getByText('$32.00')).toBeInTheDocument(); + }); +}); diff --git a/static/gsApp/views/subscriptionPage/usageOverview.tsx b/static/gsApp/views/subscriptionPage/usageOverview.tsx new file mode 100644 index 00000000000000..9eb1b985af1d67 --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview.tsx @@ -0,0 +1,621 @@ +import React, {useEffect, useMemo, useState} from 'react'; +import styled from '@emotion/styled'; + +import {Tag} from 'sentry/components/core/badge/tag'; +import {Button} from 'sentry/components/core/button'; +import {LinkButton} from 'sentry/components/core/button/linkButton'; +import {Container, Flex} from 'sentry/components/core/layout'; +import {Heading, Text} from 'sentry/components/core/text'; +import type {TextProps} from 'sentry/components/core/text/text'; +import QuestionTooltip from 'sentry/components/questionTooltip'; +import type {GridColumnOrder} from 'sentry/components/tables/gridEditable'; +import GridEditable from 'sentry/components/tables/gridEditable'; +import { + IconChevron, + IconDownload, + IconGraph, + IconLightning, + IconLock, +} from 'sentry/icons'; +import {t, tct} from 'sentry/locale'; +import type {DataCategory} from 'sentry/types/core'; +import type {Organization} from 'sentry/types/organization'; +import {defined} from 'sentry/utils'; +import {toTitleCase} from 'sentry/utils/string/toTitleCase'; +import {useNavContext} from 'sentry/views/nav/context'; + +import ProductTrialTag from 'getsentry/components/productTrial/productTrialTag'; +import StartTrialButton from 'getsentry/components/startTrialButton'; +import {RESERVED_BUDGET_QUOTA} from 'getsentry/constants'; +import {useCurrentBillingHistory} from 'getsentry/hooks/useCurrentBillingHistory'; +import { + AddOnCategory, + OnDemandBudgetMode, + type ProductTrial, + type Subscription, +} from 'getsentry/types'; +import { + displayBudgetName, + formatReservedWithUnits, + formatUsageWithUnits, + getActiveProductTrial, + getPercentage, + getPotentialProductTrial, + getReservedBudgetCategoryForAddOn, +} from 'getsentry/utils/billing'; +import {getPlanCategoryName, sortCategories} from 'getsentry/utils/dataCategory'; +import {displayPriceWithCents, getBucket} from 'getsentry/views/amCheckout/utils'; + +interface UsageOverviewProps { + organization: Organization; + subscription: Subscription; +} + +function CurrencyCell({ + children, + bold, + variant, +}: { + children: React.ReactNode; + bold?: boolean; + variant?: TextProps<'span'>['variant']; +}) { + return ( + + {children} + + ); +} + +function ReservedUsageBar({percentUsed}: {percentUsed: number}) { + if (percentUsed === 0 || percentUsed === 1) { + return ; + } + const filledWidth = percentUsed * 90; + const unfilledWidth = 90 - filledWidth; + return ( + + + + + ); +} + +function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { + const hasBillingPerms = organization.access.includes('org:billing'); + const [openState, setOpenState] = useState>({}); + const [trialButtonBusyState, setTrialButtonBusyState] = useState< + Partial> + >({}); + + useEffect(() => { + Object.entries(subscription.addOns ?? {}) + .filter( + ([_, addOnInfo]) => + !addOnInfo.billingFlag || organization.features.includes(addOnInfo.billingFlag) + ) + .forEach(([apiName, _]) => { + setOpenState(prev => ({...prev, [apiName]: true})); + }); + }, [subscription.addOns, organization.features]); + + const allAddOnDataCategories = Object.values( + subscription.planDetails.addOnCategories + ).flatMap(addOn => addOn.dataCategories); + + const columnOrder: GridColumnOrder[] = useMemo(() => { + const hasAnyPotentialProductTrial = subscription.productTrials?.some( + trial => !trial.isStarted + ); + return [ + {key: 'product', name: t('Product'), width: 300}, + {key: 'currentUsage', name: t('Current usage'), width: 200}, + {key: 'reservedUsage', name: t('Reserved usage'), width: 200}, + {key: 'reservedSpend', name: t('Reserved spend'), width: 200}, + { + key: 'budgetSpend', + name: t('%s spend', displayBudgetName(subscription.planDetails, {title: true})), + width: 200, + }, + { + key: 'cta', + name: '', + width: 50, + }, + ].filter( + column => + (hasBillingPerms || !column.key.endsWith('Spend')) && + (hasAnyPotentialProductTrial || column.key !== 'cta') + ); + }, [hasBillingPerms, subscription.planDetails, subscription.productTrials]); + + // TODO(isabella): refactor this to have better types + const productData: Array<{ + attrs: { + hasAccess: boolean; + isPaygOnly: boolean; + addOnCategory?: AddOnCategory; + ariaLabel?: string; + dataCategory?: DataCategory; + free?: number; + hasToggle?: boolean; + isChildProduct?: boolean; + isOpen?: boolean; + onToggle?: () => void; + productTrial?: ProductTrial; + reserved?: number; + }; + gridData: { + budgetSpend: number; + currentUsage: number; + product: string; + reservedSpend?: number; + reservedUsage?: number; + }; + }> = useMemo(() => { + return [ + ...sortCategories(subscription.categories) + .filter(metricHistory => !allAddOnDataCategories.includes(metricHistory.category)) + .map(metricHistory => { + const category = metricHistory.category; + const productName = getPlanCategoryName({ + plan: subscription.planDetails, + category, + title: true, + }); + const reserved = metricHistory.reserved ?? 0; + const free = metricHistory.free; + const total = metricHistory.usage; + const paygTotal = metricHistory.onDemandSpendUsed; + const activeProductTrial = getActiveProductTrial( + subscription.productTrials ?? [], + category + ); + const potentialProductTrial = getPotentialProductTrial( + subscription.productTrials ?? [], + category + ); + + const isPaygOnly = reserved === 0; + const hasAccess = activeProductTrial + ? true + : isPaygOnly + ? subscription.onDemandBudgets?.budgetMode === + OnDemandBudgetMode.PER_CATEGORY + ? metricHistory.onDemandBudget > 0 + : subscription.onDemandMaxSpend > 0 + : reserved > 0; + + const bucket = getBucket({ + events: reserved, + buckets: subscription.planDetails.planCategories[category], + }); + const recurringReservedSpend = bucket.price ?? 0; + const reservedTotal = (reserved ?? 0) + (free ?? 0); + const percentUsed = reservedTotal + ? getPercentage(total, reservedTotal) + : undefined; + + return { + attrs: { + dataCategory: category, + hasAccess, + isPaygOnly, + free, + reserved, + productTrial: activeProductTrial ?? potentialProductTrial ?? undefined, + }, + gridData: { + product: productName, + currentUsage: total, + reservedUsage: percentUsed, + reservedSpend: recurringReservedSpend, + budgetSpend: paygTotal, + }, + }; + }), + ...Object.entries(subscription.addOns ?? {}) + .filter( + ([_, addOnInfo]) => + !addOnInfo.billingFlag || + organization.features.includes(addOnInfo.billingFlag) + ) + .flatMap(([apiName, addOnInfo]) => { + const addOnName = toTitleCase(addOnInfo.productName, { + allowInnerUpperCase: true, + }); + + const paygTotal = addOnInfo.dataCategories.reduce((acc, category) => { + return acc + (subscription.categories[category]?.onDemandSpendUsed ?? 0); + }, 0); + const addOnDataCategories = addOnInfo.dataCategories; + const reservedBudgetCategory = getReservedBudgetCategoryForAddOn( + apiName as AddOnCategory + ); + const reservedBudget = reservedBudgetCategory + ? subscription.reservedBudgets?.find( + budget => (budget.apiName as string) === reservedBudgetCategory + ) + : undefined; + const percentUsed = reservedBudget?.totalReservedSpend + ? getPercentage( + reservedBudget?.totalReservedSpend, + reservedBudget?.reservedBudget + ) + : undefined; + const activeProductTrial = getActiveProductTrial( + subscription.productTrials ?? [], + addOnDataCategories[0] as DataCategory + ); + const potentialProductTrial = getPotentialProductTrial( + subscription.productTrials ?? [], + addOnDataCategories[0] as DataCategory + ); + const hasAccess = addOnInfo.enabled; + + // TODO(isabella): fix this for add-ons with multiple data categories + const bucket = hasAccess + ? getBucket({ + buckets: + subscription.planDetails.planCategories[ + addOnDataCategories[0] as DataCategory + ], + events: RESERVED_BUDGET_QUOTA, + }) + : undefined; + const recurringReservedSpend = bucket?.price ?? 0; + + const childCategoriesData = openState[apiName] + ? addOnInfo.dataCategories.map(addOnDataCategory => { + const childSpend = + reservedBudget?.categories[addOnDataCategory]?.reservedSpend ?? 0; + const childPaygTotal = + subscription.categories[addOnDataCategory]?.onDemandSpendUsed ?? 0; + const childProductName = getPlanCategoryName({ + plan: subscription.planDetails, + category: addOnDataCategory, + title: true, + }); + return { + attrs: { + addOnCategory: apiName as AddOnCategory, + isChildProduct: true, + isOpen: openState[apiName], + hasAccess: true, + isPaygOnly: false, + }, + gridData: { + budgetSpend: childPaygTotal, + currentUsage: (childSpend ?? 0) + childPaygTotal, + product: childProductName, + }, + }; + }) + : null; + + return [ + { + attrs: { + addOnCategory: apiName as AddOnCategory, + hasAccess, + free: reservedBudget?.freeBudget ?? 0, + reserved: reservedBudget?.reservedBudget ?? 0, + isPaygOnly: !reservedBudget, + productTrial: activeProductTrial ?? potentialProductTrial ?? undefined, + hasToggle: true, + isOpen: openState[apiName], + onToggle: () => { + setOpenState(prev => ({...prev, [apiName]: !prev[apiName]})); + }, + ariaLabel: openState[apiName] + ? t('Collapse %s info', addOnName) + : t('Expand %s info', addOnName), + }, + gridData: { + product: addOnName, + currentUsage: (reservedBudget?.totalReservedSpend ?? 0) + paygTotal, + reservedUsage: percentUsed, + reservedSpend: recurringReservedSpend, + budgetSpend: paygTotal, + }, + }, + ...(childCategoriesData ?? []), + ]; + }), + ]; + }, [subscription, allAddOnDataCategories, organization.features, openState]); + + return ( + product.gridData)} + columnSortBy={[]} + grid={{ + renderHeadCell: column => { + return {column.name}; + }, + renderBodyCell: (column, row) => { + const attrs = productData.find( + product => product.gridData.product === row.product + )?.attrs; + if (!attrs) { + return row[column.key as keyof typeof row]; + } + const { + dataCategory, + hasAccess, + isPaygOnly, + free, + reserved, + isOpen, + isChildProduct, + } = attrs; + + if (defined(isOpen) && !isOpen && isChildProduct) { + return null; + } + + switch (column.key) { + case 'product': { + const {hasToggle, onToggle, ariaLabel, productTrial} = attrs; + const title = ( + + + {!hasAccess && } {row.product}{' '} + {' '} + {productTrial && } + + ); + + if (hasToggle) { + return ( + + + ) : ( + + ) + } + aria-label={ariaLabel} + onClick={() => onToggle?.()} + > + {title} + + + ); + } + return ( + + {title} + + ); + } + case 'currentUsage': { + const formattedTotal = dataCategory + ? formatUsageWithUnits(row.currentUsage, dataCategory, { + useUnitScaling: true, + }) + : displayPriceWithCents({cents: row.currentUsage}); + const formattedReserved = dataCategory + ? formatReservedWithUnits(reserved ?? 0, dataCategory, { + useUnitScaling: true, + }) + : displayPriceWithCents({cents: reserved ?? 0}); + const formattedFree = dataCategory + ? formatReservedWithUnits(free ?? 0, dataCategory, { + useUnitScaling: true, + }) + : displayPriceWithCents({cents: free ?? 0}); + const formattedReservedTotal = dataCategory + ? formatReservedWithUnits((reserved ?? 0) + (free ?? 0), dataCategory, { + useUnitScaling: true, + }) + : displayPriceWithCents({cents: (reserved ?? 0) + (free ?? 0)}); + const formattedCurrentUsage = + isPaygOnly || isChildProduct + ? formattedTotal + : `${formattedTotal} / ${formattedReservedTotal}`; + return ( + + {formattedCurrentUsage}{' '} + {!(isPaygOnly || isChildProduct) && ( + + )} + + ); + } + case 'reservedUsage': { + if (isPaygOnly) { + return ( + + + {tct('[budgetTerm] only', { + budgetTerm: displayBudgetName(subscription.planDetails, { + title: true, + }), + })} + + + ); + } + const percentUsed = row.reservedUsage; + if (defined(percentUsed)) { + return ( + + + {percentUsed.toFixed(0) + '%'} + + ); + } + return
; + } + case 'reservedSpend': + case 'budgetSpend': { + const spend = row[column.key as keyof typeof row]; + const formattedSpend = spend + ? displayPriceWithCents({cents: spend as number}) + : '-'; + return {formattedSpend}; + } + case 'cta': { + const productTrial = attrs.productTrial; + if (productTrial && !productTrial.isStarted) { + return ( + + { + setTrialButtonBusyState(prev => ({ + ...prev, + [productTrial.category]: true, + })); + }} + onTrialStarted={() => { + setTrialButtonBusyState(prev => ({ + ...prev, + [productTrial.category]: true, + })); + }} + onTrialFailed={() => { + setTrialButtonBusyState(prev => ({ + ...prev, + [productTrial.category]: false, + })); + }} + busy={trialButtonBusyState[productTrial.category]} + disabled={trialButtonBusyState[productTrial.category]} + size="xs" + > + + + {t('Start 14 day free trial')} + + + + ); + } + return
; + } + default: + return row[column.key as keyof typeof row]; + } + }, + }} + /> + ); +} + +function UsageOverview({subscription, organization}: UsageOverviewProps) { + const hasBillingPerms = organization.access.includes('org:billing'); + const {isCollapsed: navIsCollapsed} = useNavContext(); + const {currentHistory, isPending, isError} = useCurrentBillingHistory(); + return ( + + + + {t('Usage Overview')} + + {hasBillingPerms && ( + + } + aria-label={t('View usage history')} + priority="link" + to="/settings/billing/usage/" + > + {t('View usage history')} + + + + )} + + + + ); +} + +export default UsageOverview; + +const StyledButton = styled(Button)` + padding: 0; +`; + +const Bar = styled('div')<{ + fillPercentage: number; + hasLeftBorderRadius?: boolean; + hasRightBorderRadius?: boolean; + width?: string; +}>` + display: block; + width: ${p => (p.width ? p.width : '90px')}; + height: 6px; + background: ${p => + p.fillPercentage === 1 + ? p.theme.danger + : p.fillPercentage > 0 + ? p.theme.active + : p.theme.gray200}; + border-top-left-radius: ${p => (p.hasLeftBorderRadius ? p.theme.borderRadius : 0)}; + border-bottom-left-radius: ${p => (p.hasLeftBorderRadius ? p.theme.borderRadius : 0)}; + border-top-right-radius: ${p => (p.hasRightBorderRadius ? p.theme.borderRadius : 0)}; + border-bottom-right-radius: ${p => (p.hasRightBorderRadius ? p.theme.borderRadius : 0)}; +`; diff --git a/static/gsApp/views/subscriptionPage/usageTotals.spec.tsx b/static/gsApp/views/subscriptionPage/usageTotals.spec.tsx index 1806cb078ef9bb..ec04db59693bff 100644 --- a/static/gsApp/views/subscriptionPage/usageTotals.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageTotals.spec.tsx @@ -1356,7 +1356,7 @@ describe('Subscription > CombinedUsageTotals', () => { expect(screen.getByTestId('locked-product-message-seer')).toHaveTextContent( 'Start your Seer trial to view usage' ); - expect(screen.getByText('Trial Available')).toBeInTheDocument(); + expect(screen.getByText('Trial available')).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Start trial'})).toBeInTheDocument(); expect(screen.queryByRole('button', {name: 'Enable Seer'})).not.toBeInTheDocument(); }); diff --git a/static/gsApp/views/subscriptionPage/usageTotals.tsx b/static/gsApp/views/subscriptionPage/usageTotals.tsx index fd989f3ab4d0f8..27f2d68c18712a 100644 --- a/static/gsApp/views/subscriptionPage/usageTotals.tsx +++ b/static/gsApp/views/subscriptionPage/usageTotals.tsx @@ -34,6 +34,7 @@ import { formatReservedWithUnits, formatUsageWithUnits, getActiveProductTrial, + getPercentage, getPotentialProductTrial, isAm2Plan, isUnlimitedReserved, @@ -71,18 +72,6 @@ const EMPTY_STAT_TOTAL = { projected: 0, }; -function getPercentage(quantity: number, total: number | null) { - if (typeof total === 'number' && total > 0) { - return (Math.min(quantity, total) / total) * 100; - } - return 0; -} - -export function displayPercentage(quantity: number, total: number | null) { - const percentage = getPercentage(quantity, total); - return percentage.toFixed(0) + '%'; -} - type UsageProps = { /** * The data category to display diff --git a/static/gsApp/views/subscriptionPage/usageTotalsTable.tsx b/static/gsApp/views/subscriptionPage/usageTotalsTable.tsx index 6bd4371fdb9745..49cd3c594a63d0 100644 --- a/static/gsApp/views/subscriptionPage/usageTotalsTable.tsx +++ b/static/gsApp/views/subscriptionPage/usageTotalsTable.tsx @@ -13,14 +13,13 @@ import type {DataCategory} from 'sentry/types/core'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; import type {BillingStatTotal, Subscription} from 'getsentry/types'; -import {formatUsageWithUnits} from 'getsentry/utils/billing'; +import {displayPercentage, formatUsageWithUnits} from 'getsentry/utils/billing'; import { getCategoryInfoFromPlural, getPlanCategoryName, isContinuousProfiling, } from 'getsentry/utils/dataCategory'; import {StripedTable} from 'getsentry/views/subscriptionPage/styles'; -import {displayPercentage} from 'getsentry/views/subscriptionPage/usageTotals'; type RowProps = { category: DataCategory;