From 394c591da6ec1dee27d5a9cb1c5a966ff1bd8dab Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Fri, 12 Sep 2025 09:56:35 -0400 Subject: [PATCH 01/17] feat(checkout v3): New Usage Overview --- .../views/subscriptionPage/usageOverview.tsx | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 static/gsApp/views/subscriptionPage/usageOverview.tsx diff --git a/static/gsApp/views/subscriptionPage/usageOverview.tsx b/static/gsApp/views/subscriptionPage/usageOverview.tsx new file mode 100644 index 00000000000000..b396b30f88d7b7 --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview.tsx @@ -0,0 +1,51 @@ +import moment from 'moment-timezone'; + +import {Button} from 'sentry/components/core/button'; +import {Container, Flex} from 'sentry/components/core/layout'; +import {Heading, Text} from 'sentry/components/core/text'; +import {IconDownload, IconGraph} from 'sentry/icons'; +import {t, tct} from 'sentry/locale'; + +import type {Subscription} from 'getsentry/types'; + +interface UsageOverviewProps { + subscription: Subscription; +} + +function UsageOverview({subscription}: UsageOverviewProps) { + const currentPeriodStart = moment(subscription.onDemandPeriodStart); + const currentPeriodEnd = moment(subscription.onDemandPeriodEnd); + const daysTilCurrentPeriodEnd = currentPeriodEnd.diff(moment(), 'days') + 1; + + return ( + + + + + {t('Usage Overview')} + + + {tct( + '[currentPeriodStart] - [currentPeriodEnd] ・ Reserved volume resets in [daysTilCurrentPeriodEnd] days left', + { + currentPeriodStart: currentPeriodStart.format('MMM D, YYYY'), + currentPeriodEnd: currentPeriodEnd.format('MMM D, YYYY'), + daysTilCurrentPeriodEnd, + } + )} + + + + + + + + + ); +} + +export default UsageOverview; From 7b675f29e3cdb0ef0d2be9045c9aaa8e821b0ae4 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Mon, 15 Sep 2025 16:09:33 -0400 Subject: [PATCH 02/17] big changes --- static/gsApp/utils/billing.tsx | 12 + .../gsApp/views/subscriptionPage/overview.tsx | 21 +- .../views/subscriptionPage/usageOverview.tsx | 428 +++++++++++++++++- .../views/subscriptionPage/usageTotals.tsx | 13 +- .../subscriptionPage/usageTotalsTable.tsx | 3 +- 5 files changed, 449 insertions(+), 28 deletions(-) diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx index c2f34efc5a3f3c..d8d1ee48e9c339 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -708,3 +708,15 @@ export function partnerPlanEndingModalIsDismissed( return true; } } + +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) + '%'; +} diff --git a/static/gsApp/views/subscriptionPage/overview.tsx b/static/gsApp/views/subscriptionPage/overview.tsx index a7539bea979c8c..39a174d8c2657d 100644 --- a/static/gsApp/views/subscriptionPage/overview.tsx +++ b/static/gsApp/views/subscriptionPage/overview.tsx @@ -31,8 +31,10 @@ import { sortCategories, } from 'getsentry/utils/dataCategory'; import withPromotions from 'getsentry/utils/withPromotions'; +import {hasCheckoutV3} from 'getsentry/views/amCheckout/utils'; import ContactBillingMembers from 'getsentry/views/contactBillingMembers'; import {openOnDemandBudgetEditModal} from 'getsentry/views/onDemandBudgets/editOnDemandButton'; +import UsageOverview from 'getsentry/views/subscriptionPage/usageOverview'; import openPerformanceQuotaCreditsPromoModal from './promotions/performanceQuotaCreditsPromo'; import openPerformanceReservedTransactionsDiscountModal from './promotions/performanceReservedTransactionsPromo'; @@ -59,6 +61,7 @@ type Props = { function Overview({location, subscription, promotionData}: Props) { const api = useApi(); const organization = useOrganization(); + const hasNewCheckout = hasCheckoutV3(organization); // TODO: change this to hasNewBillingUI const navigate = useNavigate(); const displayMode = ['cost', 'usage'].includes(location.query.displayMode as string) @@ -356,12 +359,18 @@ function Overview({location, subscription, promotionData}: Props) { - - - {renderUsageChart(usageData)} - {renderUsageCards(usageData)} - - + {hasNewCheckout ? ( + + ) : ( + + + + {renderUsageChart(usageData)} + {renderUsageCards(usageData)} + + + + )} ); } diff --git a/static/gsApp/views/subscriptionPage/usageOverview.tsx b/static/gsApp/views/subscriptionPage/usageOverview.tsx index b396b30f88d7b7..601e55c1b3ad5c 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview.tsx @@ -1,36 +1,415 @@ +import {Fragment, useEffect, useState} from 'react'; +import styled from '@emotion/styled'; import moment from 'moment-timezone'; +import {Tag} from 'sentry/components/core/badge/tag'; import {Button} from 'sentry/components/core/button'; -import {Container, Flex} from 'sentry/components/core/layout'; +import {Container, Flex, Grid} from 'sentry/components/core/layout'; import {Heading, Text} from 'sentry/components/core/text'; -import {IconDownload, IconGraph} from 'sentry/icons'; +import type {TextProps} from 'sentry/components/core/text/text'; +import {IconChevron, IconDownload, IconGraph, IconLightning} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; +import type {DataCategory} from 'sentry/types/core'; +import {capitalize} from 'sentry/utils/string/capitalize'; +import {toTitleCase} from 'sentry/utils/string/toTitleCase'; -import type {Subscription} from 'getsentry/types'; +import {OnDemandBudgetMode, type Subscription} from 'getsentry/types'; +import { + displayPercentage, + formatReservedWithUnits, + getActiveProductTrial, + getPercentage, + getPotentialProductTrial, +} from 'getsentry/utils/billing'; +import {getPlanCategoryName} from 'getsentry/utils/dataCategory'; +import {displayPriceWithCents} from 'getsentry/views/amCheckout/utils'; interface UsageOverviewProps { subscription: Subscription; } +const GRID_PROPS = { + columns: {xs: 'repeat(4, 1fr)', md: 'repeat(5, 1fr)'}, + padding: 'lg xl' as const, + borderTop: 'primary' as const, + gap: 'md' as const, + align: 'center' as const, +}; + +function PaygStatusCell({children, bold}: {children: React.ReactNode; bold?: boolean}) { + return ( + + {children} + + ); +} + +function ReservedUsageBar({percentUsed}: {percentUsed: number}) { + if (percentUsed === 0 || percentUsed === 100) { + return ( + + ); + } + const filledWidth = percentUsed * 90; + const unfilledWidth = 90 - filledWidth; + return ( + + + + + ); +} + +function UsageOverviewTable({subscription}: UsageOverviewProps) { + const [openState, setOpenState] = useState>({ + plan: true, + }); + + useEffect(() => { + Object.keys(subscription.planDetails.addOnCategories).forEach(addOn => { + setOpenState(prev => ({...prev, [addOn]: true})); + }); + }, [subscription.planDetails.addOnCategories]); + + const hasSharedPayg = + subscription.onDemandBudgets?.budgetMode === OnDemandBudgetMode.SHARED && + subscription.onDemandMaxSpend > 0; + const allAddOnDataCategories = Object.values( + subscription.planDetails.addOnCategories + ).flatMap(addOn => addOn.dataCategories); + + return ( + + {' '} + + {t('Product')} + + {t('Status')} + + {t('Current usage')} + {t('Reserved usage')} + {t('Pay-as-you-go spend')} + + + + ) : ( + + ) + } + aria-label={t('Toggle plan usage overview')} + onClick={() => setOpenState({...openState, plan: !openState.plan})} + > + + {tct('[planName] Plan', {planName: subscription.planDetails.name})} + + + + {subscription.onDemandSpendUsed > 0 + ? displayPriceWithCents({cents: subscription.onDemandSpendUsed}) + : '-'} + + + {openState.plan && ( + + {Object.entries(subscription.categories) + .filter( + ([category]) => !allAddOnDataCategories.includes(category as DataCategory) + ) + .map(([category, categoryDetails]) => { + const reserved = categoryDetails.reserved ?? 0; + const free = categoryDetails.free; + const usage = categoryDetails.usage; + const activeProductTrial = getActiveProductTrial( + subscription.productTrials ?? [], + category as DataCategory + ); + const potentialProductTrial = getPotentialProductTrial( + subscription.productTrials ?? [], + category as DataCategory + ); + + const status = activeProductTrial + ? t('Active trial') + : potentialProductTrial + ? t('Trial available') + : hasSharedPayg || reserved > 0 || categoryDetails.onDemandBudget > 0 + ? t('Active') + : tct('Requires [budgetTerm]', { + budgetTerm: subscription.planDetails.budgetTerm, + }); + const formattedUsage = formatReservedWithUnits( + usage, + category as DataCategory, + {useUnitScaling: true} + ); + const formattedReserved = formatReservedWithUnits( + reserved + free, + category as DataCategory, + {useUnitScaling: true} + ); + const percentUsed = getPercentage(usage, reserved + free); + const formattedPercentUsed = displayPercentage(usage, reserved + free); + const isPaygOnly = reserved === 0; + const formattedPaygUsed = + categoryDetails.onDemandSpendUsed > 0 + ? displayPriceWithCents({ + cents: categoryDetails.onDemandSpendUsed, + }) + : '-'; + + return ( + + + + + {getPlanCategoryName({ + plan: subscription.planDetails, + category: category as DataCategory, + title: true, + })} + + + + + {status} + + + + {isPaygOnly + ? formattedUsage + : `${formattedUsage} / ${formattedReserved}`} + + {isPaygOnly ? ( + + + {tct('[budgetTerm] only', { + budgetTerm: capitalize(subscription.planDetails.budgetTerm), + })} + + + ) : ( + + + {formattedPercentUsed} + + )} + {formattedPaygUsed} + + + ); + })} + + )} + {Object.entries(subscription.planDetails.addOnCategories).map( + ([addOn, addOnDetails]) => { + const addOnName = toTitleCase(addOnDetails.productName, { + allowInnerUpperCase: true, + }); + + const paygUsed = addOnDetails.dataCategories.reduce((acc, category) => { + return acc + (subscription.categories[category]?.onDemandSpendUsed ?? 0); + }, 0); + const addOnDataCategories = addOnDetails.dataCategories; + const reservedBudget = subscription.reservedBudgets?.find( + budget => (budget.apiName as string) === (addOnDetails.apiName as string) + ); + + const activeProductTrial = getActiveProductTrial( + subscription.productTrials ?? [], + addOnDetails.dataCategories[0] as DataCategory + ); + const potentialProductTrial = getPotentialProductTrial( + subscription.productTrials ?? [], + addOnDetails.dataCategories[0] as DataCategory + ); + // TODO: fix this to show real status + const isEnabled = (reservedBudget?.reservedBudget ?? 0) > 0; + const status = activeProductTrial + ? t('Active trial') + : potentialProductTrial + ? t('Trial available') + : isEnabled + ? t('Active') + : t('Needs purchase'); + + return ( + + + {isEnabled ? ( + + + ) : ( + + ) + } + aria-label={t('Toggle %s usage overview', addOnName)} + onClick={() => + setOpenState({...openState, [addOn]: !openState[addOn]}) + } + > + {addOnName} + + + ) : ( + {addOnName} + )} + + + {status} + + + {reservedBudget && isEnabled ? ( + + + {displayPriceWithCents({cents: reservedBudget.totalReservedSpend})}{' '} + /{' '} + {displayPriceWithCents({ + cents: reservedBudget.reservedBudget + reservedBudget.freeBudget, + })} + + + + + {displayPercentage( + reservedBudget.totalReservedSpend, + reservedBudget.reservedBudget + reservedBudget.freeBudget + )} + + + + ) : ( + +
+
+ + )} + {isEnabled ? ( + + {paygUsed > 0 ? displayPriceWithCents({cents: paygUsed}) : '-'} + + ) : potentialProductTrial ? ( + // TODO: add link to start trial + + + + ) : ( +
+ )} + + {isEnabled && openState[addOn] && ( + + {Object.entries(subscription.categories) + .filter(([category]) => + addOnDataCategories.includes(category as DataCategory) + ) + .map(([category, categoryDetails]) => { + const spend = + reservedBudget?.categories[category as DataCategory] + ?.reservedSpend ?? 0; + const formattedPaygUsed = + categoryDetails.onDemandSpendUsed > 0 + ? displayPriceWithCents({ + cents: categoryDetails.onDemandSpendUsed, + }) + : '-'; + + return ( + + + + + {getPlanCategoryName({ + plan: subscription.planDetails, + category: category as DataCategory, + title: true, + })} + + + + {reservedBudget && isEnabled ? ( + {displayPriceWithCents({cents: spend})} + ) : ( +
+ )} +
+ {formattedPaygUsed} + + + ); + })} + + )} + + ); + } + )} + +
+ +
+
+ + {tct('Total: [spend]', { + spend: displayPriceWithCents({cents: subscription.onDemandSpendUsed}), + })} + + + + ); +} + function UsageOverview({subscription}: UsageOverviewProps) { const currentPeriodStart = moment(subscription.onDemandPeriodStart); const currentPeriodEnd = moment(subscription.onDemandPeriodEnd); - const daysTilCurrentPeriodEnd = currentPeriodEnd.diff(moment(), 'days') + 1; + const newPeriodStart = moment(subscription.onDemandPeriodEnd).add(1, 'days'); + const daysTilReset = newPeriodStart.diff(moment().startOf('day'), 'days'); return ( - - + + {t('Usage Overview')} {tct( - '[currentPeriodStart] - [currentPeriodEnd] ・ Reserved volume resets in [daysTilCurrentPeriodEnd] days left', + '[currentPeriodStart] - [currentPeriodEnd] ・ Reserved volume resets in [daysTilReset] days', { currentPeriodStart: currentPeriodStart.format('MMM D, YYYY'), currentPeriodEnd: currentPeriodEnd.format('MMM D, YYYY'), - daysTilCurrentPeriodEnd, + daysTilReset, } )} @@ -44,8 +423,41 @@ function UsageOverview({subscription}: UsageOverviewProps) { + ); } export default UsageOverview; + +const StatusCell = styled(Container)` + display: block; + + @media (max-width: ${p => p.theme.breakpoints.md}) { + display: none; + } +`; + +const StyledButton = styled(Button)` + padding: 0; +`; + +const Bar = styled('div')<{ + isFilled: boolean; + hasLeftBorderRadius?: boolean; + hasRightBorderRadius?: boolean; + width?: string; +}>` + display: block; + width: ${p => (p.width ? p.width : '90px')}; + height: 6px; + background: ${p => (p.isFilled ? 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)}; + + @media (max-width: ${p => p.theme.breakpoints.sm}) { + display: none; + } +`; diff --git a/static/gsApp/views/subscriptionPage/usageTotals.tsx b/static/gsApp/views/subscriptionPage/usageTotals.tsx index d35d54157cba1b..be162293c1b44b 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; From 2adc07136adb38336fa3ee6f291f150cc73c68c9 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Tue, 16 Sep 2025 16:55:24 -0400 Subject: [PATCH 03/17] some updates --- .../views/subscriptionPage/usageOverview.tsx | 306 +++++++++--------- 1 file changed, 162 insertions(+), 144 deletions(-) diff --git a/static/gsApp/views/subscriptionPage/usageOverview.tsx b/static/gsApp/views/subscriptionPage/usageOverview.tsx index 601e55c1b3ad5c..421b4c524061cc 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview.tsx @@ -1,13 +1,14 @@ import {Fragment, useEffect, useState} from 'react'; import styled from '@emotion/styled'; +import upperFirst from 'lodash/upperFirst'; import moment from 'moment-timezone'; -import {Tag} from 'sentry/components/core/badge/tag'; +import {Tag, type TagProps} from 'sentry/components/core/badge/tag'; import {Button} from 'sentry/components/core/button'; import {Container, Flex, Grid} from 'sentry/components/core/layout'; import {Heading, Text} from 'sentry/components/core/text'; import type {TextProps} from 'sentry/components/core/text/text'; -import {IconChevron, IconDownload, IconGraph, IconLightning} from 'sentry/icons'; +import {IconChevron, IconLock} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import type {DataCategory} from 'sentry/types/core'; import {capitalize} from 'sentry/utils/string/capitalize'; @@ -28,22 +29,112 @@ interface UsageOverviewProps { subscription: Subscription; } +enum ProductStatus { + ACTIVE = 'active', + TRIAL_AVAILABLE = 'trial_available', + NEEDS_PURCHASE = 'needs_purchase', + ACTIVE_TRIAL = 'active_trial', + REQUIRES_PAYG = 'requires_payg', +} + +const PRODUCT_STATUS_TAG_TYPE = { + [ProductStatus.ACTIVE]: 'success', + [ProductStatus.TRIAL_AVAILABLE]: 'promotion', + [ProductStatus.NEEDS_PURCHASE]: 'default', + [ProductStatus.ACTIVE_TRIAL]: 'success', + [ProductStatus.REQUIRES_PAYG]: 'default', +} satisfies Record; + const GRID_PROPS = { - columns: {xs: 'repeat(4, 1fr)', md: 'repeat(5, 1fr)'}, + columns: {xs: '2fr repeat(3, 1fr)', md: '2fr repeat(4, 1fr)'}, padding: 'lg xl' as const, borderTop: 'primary' as const, gap: 'md' as const, align: 'center' as const, }; -function PaygStatusCell({children, bold}: {children: React.ReactNode; bold?: boolean}) { +function PaygStatusCell({ + children, + bold, + variant, +}: { + children: React.ReactNode; + bold?: boolean; + variant?: TextProps<'span'>['variant']; +}) { return ( - + {children} ); } +function ProductGroupHeader({ + children, + ariaLabel, + isOpen, + onToggle, + hasToggle, + onDemandSpendUsed, + status, + formattedCurrentUsage, + percentUsed, +}: { + ariaLabel: string; + children: React.ReactNode; + hasToggle: boolean; + isOpen: boolean; + onDemandSpendUsed: number; + onToggle: () => void; + formattedCurrentUsage?: string; + percentUsed?: number; + status?: ProductStatus; +}) { + return ( + + {hasToggle ? ( + + : + } + aria-label={ariaLabel} + onClick={() => onToggle()} + > + {children} + + + ) : ( + + {children} + + )} + {status ? ( + + + {tct('[status]', {status: upperFirst(status).replace('_', ' ')})} + + + ) : ( +
+ )} + {formattedCurrentUsage ? {formattedCurrentUsage} :
} + {percentUsed ? ( + + + {percentUsed.toFixed(0) + '%'} + + ) : ( +
+ )} + + {onDemandSpendUsed > 0 ? displayPriceWithCents({cents: onDemandSpendUsed}) : '-'} + + + ); +} + function ReservedUsageBar({percentUsed}: {percentUsed: number}) { if (percentUsed === 0 || percentUsed === 100) { return ( @@ -78,41 +169,31 @@ function UsageOverviewTable({subscription}: UsageOverviewProps) { subscription.planDetails.addOnCategories ).flatMap(addOn => addOn.dataCategories); + const tableHeaderProps = { + variant: 'muted' as const, + bold: true, + }; + return ( - {' '} - {t('Product')} + {t('Product')} - {t('Status')} + {t('Status')} - {t('Current usage')} - {t('Reserved usage')} - {t('Pay-as-you-go spend')} + {t('Current usage')} + {t('Reserved usage')} + {t('Pay-as-you-go spend')} - - - ) : ( - - ) - } - aria-label={t('Toggle plan usage overview')} - onClick={() => setOpenState({...openState, plan: !openState.plan})} - > - - {tct('[planName] Plan', {planName: subscription.planDetails.name})} - - - - {subscription.onDemandSpendUsed > 0 - ? displayPriceWithCents({cents: subscription.onDemandSpendUsed}) - : '-'} - - + setOpenState({...openState, plan: !openState.plan})} + hasToggle + onDemandSpendUsed={subscription.onDemandSpendUsed} + > + {tct('[planName] Plan', {planName: subscription.planDetails.name})} + {openState.plan && ( {Object.entries(subscription.categories) @@ -132,15 +213,24 @@ function UsageOverviewTable({subscription}: UsageOverviewProps) { category as DataCategory ); + const isPaygOnly = reserved === 0; + const hasAccess = + reserved > 0 || + (isPaygOnly && (hasSharedPayg || categoryDetails.onDemandBudget > 0)); const status = activeProductTrial - ? t('Active trial') + ? ProductStatus.ACTIVE_TRIAL : potentialProductTrial - ? t('Trial available') - : hasSharedPayg || reserved > 0 || categoryDetails.onDemandBudget > 0 - ? t('Active') - : tct('Requires [budgetTerm]', { - budgetTerm: subscription.planDetails.budgetTerm, - }); + ? ProductStatus.TRIAL_AVAILABLE + : hasAccess + ? ProductStatus.ACTIVE + : ProductStatus.REQUIRES_PAYG; + const formattedStatus = + status === ProductStatus.REQUIRES_PAYG + ? tct('Requires [budgetTerm]', { + budgetTerm: subscription.planDetails.budgetTerm, + }) + : tct('[status]', {status: upperFirst(status).replace('_', ' ')}); + const formattedUsage = formatReservedWithUnits( usage, category as DataCategory, @@ -153,7 +243,6 @@ function UsageOverviewTable({subscription}: UsageOverviewProps) { ); const percentUsed = getPercentage(usage, reserved + free); const formattedPercentUsed = displayPercentage(usage, reserved + free); - const isPaygOnly = reserved === 0; const formattedPaygUsed = categoryDetails.onDemandSpendUsed > 0 ? displayPriceWithCents({ @@ -164,27 +253,22 @@ function UsageOverviewTable({subscription}: UsageOverviewProps) { return ( - - + + {isPaygOnly && !hasAccess && ( + + + + )} + {getPlanCategoryName({ plan: subscription.planDetails, category: category as DataCategory, title: true, })} - + - - {status} - + {formattedStatus} {isPaygOnly @@ -237,91 +321,33 @@ function UsageOverviewTable({subscription}: UsageOverviewProps) { // TODO: fix this to show real status const isEnabled = (reservedBudget?.reservedBudget ?? 0) > 0; const status = activeProductTrial - ? t('Active trial') + ? ProductStatus.ACTIVE_TRIAL : potentialProductTrial - ? t('Trial available') + ? ProductStatus.TRIAL_AVAILABLE : isEnabled - ? t('Active') - : t('Needs purchase'); + ? ProductStatus.ACTIVE + : ProductStatus.NEEDS_PURCHASE; return ( - - {isEnabled ? ( - - - ) : ( - - ) - } - aria-label={t('Toggle %s usage overview', addOnName)} - onClick={() => - setOpenState({...openState, [addOn]: !openState[addOn]}) - } - > - {addOnName} - - - ) : ( - {addOnName} - )} - - - {status} - - - {reservedBudget && isEnabled ? ( - - - {displayPriceWithCents({cents: reservedBudget.totalReservedSpend})}{' '} - /{' '} - {displayPriceWithCents({ - cents: reservedBudget.reservedBudget + reservedBudget.freeBudget, - })} - - - - - {displayPercentage( - reservedBudget.totalReservedSpend, - reservedBudget.reservedBudget + reservedBudget.freeBudget - )} - - - - ) : ( - -
-
- - )} - {isEnabled ? ( - - {paygUsed > 0 ? displayPriceWithCents({cents: paygUsed}) : '-'} - - ) : potentialProductTrial ? ( - // TODO: add link to start trial - - - - ) : ( -
- )} - + setOpenState({...openState, [addOn]: !openState[addOn]})} + hasToggle={isEnabled} + onDemandSpendUsed={paygUsed} + status={status} + formattedCurrentUsage={ + reservedBudget && isEnabled + ? `${displayPriceWithCents({cents: reservedBudget.totalReservedSpend})} / ${displayPriceWithCents({cents: reservedBudget.reservedBudget + reservedBudget.freeBudget})}` + : undefined + } + percentUsed={ + reservedBudget && isEnabled ? reservedBudget.percentUsed : undefined + } + > + {addOnName} + {isEnabled && openState[addOn] && ( {Object.entries(subscription.categories) @@ -414,14 +440,6 @@ function UsageOverview({subscription}: UsageOverviewProps) { )} - - - - From 94f2ca536a3105bf9fde2cad4947c389290d3f18 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Tue, 16 Sep 2025 17:32:03 -0400 Subject: [PATCH 04/17] more cleanup --- .../views/subscriptionPage/usageOverview.tsx | 171 +++++++++--------- 1 file changed, 85 insertions(+), 86 deletions(-) diff --git a/static/gsApp/views/subscriptionPage/usageOverview.tsx b/static/gsApp/views/subscriptionPage/usageOverview.tsx index 421b4c524061cc..1aab3395f0a06f 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview.tsx @@ -1,4 +1,4 @@ -import {Fragment, useEffect, useState} from 'react'; +import React, {Fragment, useEffect, useState} from 'react'; import styled from '@emotion/styled'; import upperFirst from 'lodash/upperFirst'; import moment from 'moment-timezone'; @@ -8,15 +8,13 @@ import {Button} from 'sentry/components/core/button'; import {Container, Flex, Grid} from 'sentry/components/core/layout'; import {Heading, Text} from 'sentry/components/core/text'; import type {TextProps} from 'sentry/components/core/text/text'; -import {IconChevron, IconLock} from 'sentry/icons'; +import {IconChevron, IconLightning, IconLock} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import type {DataCategory} from 'sentry/types/core'; -import {capitalize} from 'sentry/utils/string/capitalize'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; -import {OnDemandBudgetMode, type Subscription} from 'getsentry/types'; +import {OnDemandBudgetMode, type Plan, type Subscription} from 'getsentry/types'; import { - displayPercentage, formatReservedWithUnits, getActiveProductTrial, getPercentage, @@ -69,8 +67,9 @@ function PaygStatusCell({ ); } -function ProductGroupHeader({ - children, +function ProductRow({ + plan, + productName, ariaLabel, isOpen, onToggle, @@ -79,19 +78,33 @@ function ProductGroupHeader({ status, formattedCurrentUsage, percentUsed, + isGroup, + icon, }: { - ariaLabel: string; - children: React.ReactNode; hasToggle: boolean; - isOpen: boolean; onDemandSpendUsed: number; - onToggle: () => void; + plan: Plan; + productName: React.ReactNode; + ariaLabel?: string; formattedCurrentUsage?: string; + icon?: React.ReactNode; + isGroup?: boolean; + isOpen?: boolean; + onToggle?: () => void; percentUsed?: number; status?: ProductStatus; }) { + const title = ( + + {icon} + + {' '} + {productName} + + + ); return ( - + {hasToggle ? ( : } aria-label={ariaLabel} - onClick={() => onToggle()} + onClick={() => onToggle?.()} > - {children} + {title} ) : ( - - {children} - + {title} )} - {status ? ( - + + {status ? ( - {tct('[status]', {status: upperFirst(status).replace('_', ' ')})} + {status === ProductStatus.REQUIRES_PAYG + ? tct('Requires [budgetTerm]', { + budgetTerm: plan.budgetTerm, + }) + : tct('[status]', {status: upperFirst(status).replace('_', ' ')})} - - ) : ( -
- )} + ) : ( +
+ )} + {formattedCurrentUsage ? {formattedCurrentUsage} :
} {percentUsed ? ( @@ -128,9 +143,24 @@ function ProductGroupHeader({ ) : (
)} - - {onDemandSpendUsed > 0 ? displayPriceWithCents({cents: onDemandSpendUsed}) : '-'} - + {status === ProductStatus.TRIAL_AVAILABLE ? ( + + + + ) : ( + + {onDemandSpendUsed > 0 + ? displayPriceWithCents({cents: onDemandSpendUsed}) + : '-'} + + )} ); } @@ -185,15 +215,16 @@ function UsageOverviewTable({subscription}: UsageOverviewProps) { {t('Reserved usage')} {t('Pay-as-you-go spend')} - setOpenState({...openState, plan: !openState.plan})} hasToggle onDemandSpendUsed={subscription.onDemandSpendUsed} - > - {tct('[planName] Plan', {planName: subscription.planDetails.name})} - + productName={tct('[planName] Plan', {planName: subscription.planDetails.name})} + isGroup + /> {openState.plan && ( {Object.entries(subscription.categories) @@ -201,6 +232,11 @@ function UsageOverviewTable({subscription}: UsageOverviewProps) { ([category]) => !allAddOnDataCategories.includes(category as DataCategory) ) .map(([category, categoryDetails]) => { + const productName = getPlanCategoryName({ + plan: subscription.planDetails, + category: category as DataCategory, + title: true, + }); const reserved = categoryDetails.reserved ?? 0; const free = categoryDetails.free; const usage = categoryDetails.usage; @@ -224,12 +260,6 @@ function UsageOverviewTable({subscription}: UsageOverviewProps) { : hasAccess ? ProductStatus.ACTIVE : ProductStatus.REQUIRES_PAYG; - const formattedStatus = - status === ProductStatus.REQUIRES_PAYG - ? tct('Requires [budgetTerm]', { - budgetTerm: subscription.planDetails.budgetTerm, - }) - : tct('[status]', {status: upperFirst(status).replace('_', ' ')}); const formattedUsage = formatReservedWithUnits( usage, @@ -242,55 +272,23 @@ function UsageOverviewTable({subscription}: UsageOverviewProps) { {useUnitScaling: true} ); const percentUsed = getPercentage(usage, reserved + free); - const formattedPercentUsed = displayPercentage(usage, reserved + free); - const formattedPaygUsed = - categoryDetails.onDemandSpendUsed > 0 - ? displayPriceWithCents({ - cents: categoryDetails.onDemandSpendUsed, - }) - : '-'; return ( - - - {isPaygOnly && !hasAccess && ( - - - - )} - - {getPlanCategoryName({ - plan: subscription.planDetails, - category: category as DataCategory, - title: true, - })} - - - - {formattedStatus} - - - {isPaygOnly + : undefined} + status={status} + formattedCurrentUsage={ + isPaygOnly ? formattedUsage - : `${formattedUsage} / ${formattedReserved}`} - - {isPaygOnly ? ( - - - {tct('[budgetTerm] only', { - budgetTerm: capitalize(subscription.planDetails.budgetTerm), - })} - - - ) : ( - - - {formattedPercentUsed} - - )} - {formattedPaygUsed} - + : `${formattedUsage} / ${formattedReserved}` + } + percentUsed={percentUsed} + /> ); })} @@ -330,7 +328,8 @@ function UsageOverviewTable({subscription}: UsageOverviewProps) { return ( - setOpenState({...openState, [addOn]: !openState[addOn]})} @@ -345,9 +344,9 @@ function UsageOverviewTable({subscription}: UsageOverviewProps) { percentUsed={ reservedBudget && isEnabled ? reservedBudget.percentUsed : undefined } - > - {addOnName} - + productName={addOnName} + isGroup + /> {isEnabled && openState[addOn] && ( {Object.entries(subscription.categories) From 40c052ffcdf21ea52175b335450e20d641d1241a Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Tue, 7 Oct 2025 14:19:11 -0400 Subject: [PATCH 05/17] some improvements --- .../productTrial/productTrialTag.tsx | 8 +- .../gsApp/hooks/useCurrentBillingHistory.tsx | 25 + .../gsApp/views/subscriptionPage/overview.tsx | 6 +- .../views/subscriptionPage/usageOverview.tsx | 511 +++++++++--------- 4 files changed, 285 insertions(+), 265 deletions(-) create mode 100644 static/gsApp/hooks/useCurrentBillingHistory.tsx diff --git a/static/gsApp/components/productTrial/productTrialTag.tsx b/static/gsApp/components/productTrial/productTrialTag.tsx index a105ddce8bcb81..2f33db4562ebf3 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 <= 14 ? 'warning' : 'highlight'); return ( } type={tagType}> diff --git a/static/gsApp/hooks/useCurrentBillingHistory.tsx b/static/gsApp/hooks/useCurrentBillingHistory.tsx new file mode 100644 index 00000000000000..9500751dc00758 --- /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: Infinity, // this changes once a month, so we can cache for a long time + }); + + 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/views/subscriptionPage/overview.tsx b/static/gsApp/views/subscriptionPage/overview.tsx index 6b9faa82e1d5c1..457d7e2f795016 100644 --- a/static/gsApp/views/subscriptionPage/overview.tsx +++ b/static/gsApp/views/subscriptionPage/overview.tsx @@ -61,7 +61,7 @@ type Props = { function Overview({location, subscription, promotionData}: Props) { const api = useApi(); const organization = useOrganization(); - const hasNewCheckout = hasNewBillingUI(organization); + const isNewBillingUI = hasNewBillingUI(organization); const navigate = useNavigate(); const displayMode = ['cost', 'usage'].includes(location.query.displayMode as string) @@ -367,8 +367,8 @@ function Overview({location, subscription, promotionData}: Props) { - {hasNewCheckout ? ( - + {isNewBillingUI ? ( + ) : ( diff --git a/static/gsApp/views/subscriptionPage/usageOverview.tsx b/static/gsApp/views/subscriptionPage/usageOverview.tsx index 1aab3395f0a06f..5b52ed3bbd5833 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview.tsx @@ -1,57 +1,60 @@ import React, {Fragment, useEffect, useState} from 'react'; import styled from '@emotion/styled'; -import upperFirst from 'lodash/upperFirst'; -import moment from 'moment-timezone'; -import {Tag, type TagProps} from 'sentry/components/core/badge/tag'; +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, Grid} from 'sentry/components/core/layout'; import {Heading, Text} from 'sentry/components/core/text'; import type {TextProps} from 'sentry/components/core/text/text'; -import {IconChevron, IconLightning, IconLock} from 'sentry/icons'; +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 {OnDemandBudgetMode, type Plan, type Subscription} from 'getsentry/types'; +import ProductTrialTag from 'getsentry/components/productTrial/productTrialTag'; +import {RESERVED_BUDGET_QUOTA} from 'getsentry/constants'; +import {useCurrentBillingHistory} from 'getsentry/hooks/useCurrentBillingHistory'; +import { + AddOnCategory, + OnDemandBudgetMode, + type Plan, + type ProductTrial, + type Subscription, +} from 'getsentry/types'; import { + displayBudgetName, formatReservedWithUnits, getActiveProductTrial, getPercentage, getPotentialProductTrial, + getReservedBudgetCategoryForAddOn, } from 'getsentry/utils/billing'; -import {getPlanCategoryName} from 'getsentry/utils/dataCategory'; -import {displayPriceWithCents} from 'getsentry/views/amCheckout/utils'; +import {getPlanCategoryName, sortCategories} from 'getsentry/utils/dataCategory'; +import {displayPriceWithCents, getBucket} from 'getsentry/views/amCheckout/utils'; interface UsageOverviewProps { + organization: Organization; subscription: Subscription; } -enum ProductStatus { - ACTIVE = 'active', - TRIAL_AVAILABLE = 'trial_available', - NEEDS_PURCHASE = 'needs_purchase', - ACTIVE_TRIAL = 'active_trial', - REQUIRES_PAYG = 'requires_payg', -} - -const PRODUCT_STATUS_TAG_TYPE = { - [ProductStatus.ACTIVE]: 'success', - [ProductStatus.TRIAL_AVAILABLE]: 'promotion', - [ProductStatus.NEEDS_PURCHASE]: 'default', - [ProductStatus.ACTIVE_TRIAL]: 'success', - [ProductStatus.REQUIRES_PAYG]: 'default', -} satisfies Record; - const GRID_PROPS = { - columns: {xs: '2fr repeat(3, 1fr)', md: '2fr repeat(4, 1fr)'}, + columns: {xs: '2fr repeat(4, 1fr)', md: '2fr repeat(5, 1fr)'}, padding: 'lg xl' as const, borderTop: 'primary' as const, gap: 'md' as const, align: 'center' as const, }; -function PaygStatusCell({ +function CurrencyCell({ children, bold, variant, @@ -69,42 +72,52 @@ function PaygStatusCell({ function ProductRow({ plan, + hasAccess, productName, ariaLabel, isOpen, onToggle, hasToggle, onDemandSpendUsed, - status, formattedCurrentUsage, percentUsed, - isGroup, - icon, + isTopMostProduct, + potentialProductTrial, + activeProductTrial, + recurringReservedSpend, + isPaygOnly, }: { + activeProductTrial: ProductTrial | null; + hasAccess: boolean; hasToggle: boolean; + isPaygOnly: boolean; onDemandSpendUsed: number; plan: Plan; + potentialProductTrial: ProductTrial | null; productName: React.ReactNode; - ariaLabel?: string; + recurringReservedSpend: number; + ariaLabel?: string; // fix type so this is always required if hasToggle is true formattedCurrentUsage?: string; - icon?: React.ReactNode; - isGroup?: boolean; isOpen?: boolean; + isTopMostProduct?: boolean; onToggle?: () => void; percentUsed?: number; - status?: ProductStatus; }) { const title = ( - - {icon} - - {' '} - {productName} - - + + + {!hasAccess && } + + {' '} + {productName} + + + {potentialProductTrial && } + {activeProductTrial && } + ); return ( - + {hasToggle ? ( ) : ( - {title} + {title} )} - - {status ? ( - - {status === ProductStatus.REQUIRES_PAYG - ? tct('Requires [budgetTerm]', { - budgetTerm: plan.budgetTerm, - }) - : tct('[status]', {status: upperFirst(status).replace('_', ' ')})} - - ) : ( -
- )} - {formattedCurrentUsage ? {formattedCurrentUsage} :
} - {percentUsed ? ( + {isPaygOnly ? ( + + + {tct('[budgetTerm] only', { + budgetTerm: displayBudgetName(plan, {title: true}), + })} + + + ) : defined(percentUsed) ? ( - + {percentUsed.toFixed(0) + '%'} ) : (
)} - {status === ProductStatus.TRIAL_AVAILABLE ? ( + + {recurringReservedSpend > 0 + ? displayPriceWithCents({cents: recurringReservedSpend}) + : '-'} + + + {onDemandSpendUsed > 0 ? displayPriceWithCents({cents: onDemandSpendUsed}) : '-'} + + {potentialProductTrial && ( + - + ); } export default UsageOverview; -const StatusCell = styled(Container)` - display: block; - - @media (max-width: ${p => p.theme.breakpoints.md}) { - display: none; - } -`; - const StyledButton = styled(Button)` padding: 0; `; const Bar = styled('div')<{ - isFilled: boolean; + fillPercentage: number; hasLeftBorderRadius?: boolean; hasRightBorderRadius?: boolean; width?: string; @@ -468,7 +458,12 @@ const Bar = styled('div')<{ display: block; width: ${p => (p.width ? p.width : '90px')}; height: 6px; - background: ${p => (p.isFilled ? p.theme.active : p.theme.gray200)}; + background: ${p => + p.fillPercentage === 100 + ? 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)}; From 54e46ff0d0e4e817a2b222508e93615a17d6bd5d Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Tue, 7 Oct 2025 15:44:04 -0400 Subject: [PATCH 06/17] save --- .../views/subscriptionPage/usageOverview.tsx | 322 +++++++++++++----- 1 file changed, 231 insertions(+), 91 deletions(-) diff --git a/static/gsApp/views/subscriptionPage/usageOverview.tsx b/static/gsApp/views/subscriptionPage/usageOverview.tsx index 5b52ed3bbd5833..f2b24c80a15f18 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview.tsx @@ -1,4 +1,5 @@ import React, {Fragment, useEffect, useState} from 'react'; +// import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {Tag} from 'sentry/components/core/badge/tag'; @@ -7,6 +8,7 @@ import {LinkButton} from 'sentry/components/core/button/linkButton'; import {Container, Flex, Grid} 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 { IconChevron, IconDownload, @@ -20,7 +22,10 @@ import type {Organization} from 'sentry/types/organization'; import {defined} from 'sentry/utils'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; +// import useMedia from 'sentry/utils/useMedia'; + 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 { @@ -33,6 +38,7 @@ import { import { displayBudgetName, formatReservedWithUnits, + formatUsageWithUnits, getActiveProductTrial, getPercentage, getPotentialProductTrial, @@ -46,12 +52,85 @@ interface UsageOverviewProps { subscription: Subscription; } +type ToggleableProductRowProps = { + ariaLabel: string; + hasToggle: true; + isOpen: boolean; + onToggle: () => void; +}; + +type NonToggleableProductRowProps = { + hasToggle: false; + ariaLabel?: never; + isOpen?: never; + onToggle?: never; +}; + +type DataCategoryProductRowProps = { + dataCategory: DataCategory; + addOnCategory?: never; +}; + +type AddOnCategoryProductRowProps = { + addOnCategory: AddOnCategory; + dataCategory?: never; +}; + +type ProductRowProps = (ToggleableProductRowProps | NonToggleableProductRowProps) & + (DataCategoryProductRowProps | AddOnCategoryProductRowProps) & { + /** + * Whether the customer has access to the product + */ + hasAccess: boolean; + organization: Organization; + /** + * PAYG usage, in cents + */ + paygTotal: number; + plan: Plan; + /** + * The display name for the product + */ + productName: string; + /** + * Total reserved, in events for volume-based or cents for budget-based + */ + total: number; + /** + * Gifted total, in events for volume-based or cents for budget-based + */ + free?: number; + /** + * Whether the product is the top-most in its hierarchy + */ + isTopMostProduct?: boolean; + /** + * The active product trial for the product, if available, otherwise the potential product trial, if available + */ + productTrial?: ProductTrial; + /** + * The recurring cost for the reserved volume or budget for the product + */ + recurringReservedSpend?: number; + /** + * Reserved total, in events for volume-based or cents for budget-based + * This is 0 for pay-as-you-go only products + */ + reserved?: number; + }; + +// the breakpoints at which we condense and expand the usage overview table +// const MAX_BREAKPOINT_TO_CONDENSE = 'md'; +// const MIN_BREAKPOINT_TO_EXPAND = 'lg'; + const GRID_PROPS = { - columns: {xs: '2fr repeat(4, 1fr)', md: '2fr repeat(5, 1fr)'}, + columns: '2fr repeat(5, 1fr)', padding: 'lg xl' as const, borderTop: 'primary' as const, - gap: 'md' as const, + gap: '2xl' as const, align: 'center' as const, + overflowX: 'scroll' as const, + whiteSpace: 'nowrap' as const, }; function CurrencyCell({ @@ -74,37 +153,28 @@ function ProductRow({ plan, hasAccess, productName, + dataCategory, ariaLabel, isOpen, onToggle, hasToggle, - onDemandSpendUsed, - formattedCurrentUsage, - percentUsed, isTopMostProduct, - potentialProductTrial, - activeProductTrial, + productTrial, recurringReservedSpend, - isPaygOnly, -}: { - activeProductTrial: ProductTrial | null; - hasAccess: boolean; - hasToggle: boolean; - isPaygOnly: boolean; - onDemandSpendUsed: number; - plan: Plan; - potentialProductTrial: ProductTrial | null; - productName: React.ReactNode; - recurringReservedSpend: number; - ariaLabel?: string; // fix type so this is always required if hasToggle is true - formattedCurrentUsage?: string; - isOpen?: boolean; - isTopMostProduct?: boolean; - onToggle?: () => void; - percentUsed?: number; -}) { + free, + reserved, + paygTotal, + total, + organization, +}: ProductRowProps) { + // const theme = useTheme(); + // const isScreenSmall = useMedia( + // `(max-width: ${theme.breakpoints[MIN_BREAKPOINT_TO_EXPAND]})` + // ); + + const [trialButtonBusy, setTrialButtonBusy] = useState(false); const title = ( - + {!hasAccess && } @@ -112,10 +182,37 @@ function ProductRow({ {productName} - {potentialProductTrial && } - {activeProductTrial && } + {productTrial && } ); + + const formattedTotal = dataCategory + ? formatUsageWithUnits(total, dataCategory, {useUnitScaling: true}) + : displayPriceWithCents({cents: total}); + const reservedTotal = (reserved ?? 0) + (free ?? 0); + const formattedReserved = reservedTotal + ? dataCategory + ? formatReservedWithUnits(reservedTotal, dataCategory, {useUnitScaling: true}) + : displayPriceWithCents({cents: reservedTotal}) + : undefined; + const formattedFree = free + ? dataCategory + ? formatReservedWithUnits(free, dataCategory, {useUnitScaling: true}) + : displayPriceWithCents({cents: free}) + : undefined; + const formattedCurrentUsage = + isTopMostProduct && formattedReserved + ? `${formattedTotal} / ${formattedReserved}` + : formattedTotal; + const percentUsed = reservedTotal ? getPercentage(total, reservedTotal) : undefined; + const formattedRecurringReservedSpend = recurringReservedSpend + ? displayPriceWithCents({cents: recurringReservedSpend}) + : '-'; + const formattedPaygTotal = + paygTotal > 0 ? displayPriceWithCents({cents: paygTotal}) : '-'; + + const isPaygOnly = reserved === 0; + return ( {hasToggle ? ( @@ -134,41 +231,82 @@ function ProductRow({ ) : ( {title} )} - {formattedCurrentUsage ? {formattedCurrentUsage} :
} + + + {formattedCurrentUsage}{' '} + {!isPaygOnly && ( + + )} + + {isPaygOnly ? ( - + {tct('[budgetTerm] only', { - budgetTerm: displayBudgetName(plan, {title: true}), + budgetTerm: + // isScreenSmall + // ? plan.budgetTerm === 'pay-as-you-go' + // ? 'PAYG' + // : 'OD' + // : + displayBudgetName(plan, {title: true}), })} ) : defined(percentUsed) ? ( - + {percentUsed.toFixed(0) + '%'} ) : (
)} - - {recurringReservedSpend > 0 - ? displayPriceWithCents({cents: recurringReservedSpend}) - : '-'} - - - {onDemandSpendUsed > 0 ? displayPriceWithCents({cents: onDemandSpendUsed}) : '-'} - - {potentialProductTrial && ( - - + + + + {/* {isScreenSmall ? t('Start trial') : */} + {t('Start 14 day free trial')} + {/* } */} + + + )} @@ -176,13 +314,13 @@ function ProductRow({ } function ReservedUsageBar({percentUsed}: {percentUsed: number}) { - if (percentUsed === 0 || percentUsed === 100) { + if (percentUsed === 0 || percentUsed === 1) { return ; } const filledWidth = percentUsed * 90; const unfilledWidth = 90 - filledWidth; return ( - + @@ -219,7 +357,11 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { {t('Current usage')} {t('Reserved usage')} {t('Reserved spend')} - {t('Pay-as-you-go spend')} + + {tct('[budgetTerm] spend', { + budgetTerm: displayBudgetName(subscription.planDetails, {title: true}), + })} + {sortCategories(subscription.categories) .filter(metricHistory => !allAddOnDataCategories.includes(metricHistory.category)) @@ -252,14 +394,6 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { : subscription.onDemandMaxSpend > 0 : reserved > 0; - const formattedUsage = formatReservedWithUnits(usage, category, { - useUnitScaling: true, - }); - const formattedReserved = formatReservedWithUnits(reserved + free, category, { - useUnitScaling: true, - }); - const percentUsed = getPercentage(usage, reserved + free); - const bucket = getBucket({ events: reserved, buckets: subscription.planDetails.planCategories[category], @@ -269,20 +403,19 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { return ( ); })} @@ -309,7 +442,6 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { budget => (budget.apiName as string) === reservedBudgetCategory ) : undefined; - const isPaygOnly = reservedBudget ? false : true; const activeProductTrial = getActiveProductTrial( subscription.productTrials ?? [], @@ -333,9 +465,29 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { : undefined; const recurringReservedSpend = bucket?.price ?? 0; + if (!isEnabled) { + ; + } + return ( setOpenState({...openState, [apiName]: !openState[apiName]}) } - hasToggle={isEnabled} - onDemandSpendUsed={paygUsed} - isPaygOnly={isPaygOnly} + hasToggle + reserved={reservedBudget?.reservedBudget} + free={reservedBudget?.freeBudget} + total={reservedBudget?.totalReservedSpend ?? 0} + paygTotal={paygUsed} hasAccess={isEnabled} - formattedCurrentUsage={ - reservedBudget - ? `${displayPriceWithCents({cents: reservedBudget.totalReservedSpend})} / ${displayPriceWithCents({cents: reservedBudget.reservedBudget + reservedBudget.freeBudget})}` - : undefined - } - percentUsed={ - reservedBudget && isEnabled ? reservedBudget.percentUsed : undefined - } productName={addOnName} isTopMostProduct - potentialProductTrial={potentialProductTrial} - activeProductTrial={activeProductTrial} + productTrial={activeProductTrial ?? potentialProductTrial ?? undefined} /> {isEnabled && openState[apiName] && ( @@ -378,17 +523,16 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { return ( ); })} @@ -459,7 +603,7 @@ const Bar = styled('div')<{ width: ${p => (p.width ? p.width : '90px')}; height: 6px; background: ${p => - p.fillPercentage === 100 + p.fillPercentage === 1 ? p.theme.danger : p.fillPercentage > 0 ? p.theme.active @@ -468,8 +612,4 @@ const Bar = styled('div')<{ 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)}; - - @media (max-width: ${p => p.theme.breakpoints.sm}) { - display: none; - } `; From b247ee9c3c0625d41cad1bd08125def7b4b55e92 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Tue, 7 Oct 2025 17:32:22 -0400 Subject: [PATCH 07/17] use grideditable --- .../components/subscriptionSettingsLayout.tsx | 2 +- .../views/subscriptionPage/usageOverview.tsx | 642 ++++++++++-------- 2 files changed, 361 insertions(+), 283 deletions(-) diff --git a/static/gsApp/components/subscriptionSettingsLayout.tsx b/static/gsApp/components/subscriptionSettingsLayout.tsx index 6d55a2aaaff754..dbdedfea0e2d33 100644 --- a/static/gsApp/components/subscriptionSettingsLayout.tsx +++ b/static/gsApp/components/subscriptionSettingsLayout.tsx @@ -50,7 +50,7 @@ function SubscriptionSettingsLayout(props: Props) { - + {children} diff --git a/static/gsApp/views/subscriptionPage/usageOverview.tsx b/static/gsApp/views/subscriptionPage/usageOverview.tsx index f2b24c80a15f18..566c519603ea2c 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview.tsx @@ -1,14 +1,15 @@ -import React, {Fragment, useEffect, useState} from 'react'; -// import {useTheme} from '@emotion/react'; +import React, {Fragment, 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, Grid} from 'sentry/components/core/layout'; +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, @@ -22,8 +23,6 @@ import type {Organization} from 'sentry/types/organization'; import {defined} from 'sentry/utils'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; -// import useMedia from 'sentry/utils/useMedia'; - import ProductTrialTag from 'getsentry/components/productTrial/productTrialTag'; import StartTrialButton from 'getsentry/components/startTrialButton'; import {RESERVED_BUDGET_QUOTA} from 'getsentry/constants'; @@ -149,170 +148,6 @@ function CurrencyCell({ ); } -function ProductRow({ - plan, - hasAccess, - productName, - dataCategory, - ariaLabel, - isOpen, - onToggle, - hasToggle, - isTopMostProduct, - productTrial, - recurringReservedSpend, - free, - reserved, - paygTotal, - total, - organization, -}: ProductRowProps) { - // const theme = useTheme(); - // const isScreenSmall = useMedia( - // `(max-width: ${theme.breakpoints[MIN_BREAKPOINT_TO_EXPAND]})` - // ); - - const [trialButtonBusy, setTrialButtonBusy] = useState(false); - const title = ( - - - {!hasAccess && } - - {' '} - {productName} - - - {productTrial && } - - ); - - const formattedTotal = dataCategory - ? formatUsageWithUnits(total, dataCategory, {useUnitScaling: true}) - : displayPriceWithCents({cents: total}); - const reservedTotal = (reserved ?? 0) + (free ?? 0); - const formattedReserved = reservedTotal - ? dataCategory - ? formatReservedWithUnits(reservedTotal, dataCategory, {useUnitScaling: true}) - : displayPriceWithCents({cents: reservedTotal}) - : undefined; - const formattedFree = free - ? dataCategory - ? formatReservedWithUnits(free, dataCategory, {useUnitScaling: true}) - : displayPriceWithCents({cents: free}) - : undefined; - const formattedCurrentUsage = - isTopMostProduct && formattedReserved - ? `${formattedTotal} / ${formattedReserved}` - : formattedTotal; - const percentUsed = reservedTotal ? getPercentage(total, reservedTotal) : undefined; - const formattedRecurringReservedSpend = recurringReservedSpend - ? displayPriceWithCents({cents: recurringReservedSpend}) - : '-'; - const formattedPaygTotal = - paygTotal > 0 ? displayPriceWithCents({cents: paygTotal}) : '-'; - - const isPaygOnly = reserved === 0; - - return ( - - {hasToggle ? ( - - : - } - aria-label={ariaLabel} - onClick={() => onToggle?.()} - > - {title} - - - ) : ( - {title} - )} - - - {formattedCurrentUsage}{' '} - {!isPaygOnly && ( - - )} - - - {isPaygOnly ? ( - - - {tct('[budgetTerm] only', { - budgetTerm: - // isScreenSmall - // ? plan.budgetTerm === 'pay-as-you-go' - // ? 'PAYG' - // : 'OD' - // : - displayBudgetName(plan, {title: true}), - })} - - - ) : defined(percentUsed) ? ( - - - {percentUsed.toFixed(0) + '%'} - - ) : ( -
- )} - {formattedRecurringReservedSpend} - {formattedPaygTotal} - {productTrial && !productTrial.isStarted && ( - - { - setTrialButtonBusy(true); - }} - onTrialStarted={() => { - setTrialButtonBusy(true); - }} - onTrialFailed={() => { - setTrialButtonBusy(false); - }} - busy={trialButtonBusy} - disabled={trialButtonBusy} - size="xs" - > - - - - {/* {isScreenSmall ? t('Start trial') : */} - {t('Start 14 day free trial')} - {/* } */} - - - - - )} - - ); -} - function ReservedUsageBar({percentUsed}: {percentUsed: number}) { if (percentUsed === 0 || percentUsed === 1) { return ; @@ -350,20 +185,46 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { bold: true, }; - return ( - - - {t('Product')} - {t('Current usage')} - {t('Reserved usage')} - {t('Reserved spend')} - - {tct('[budgetTerm] spend', { - budgetTerm: displayBudgetName(subscription.planDetails, {title: true}), - })} - - - {sortCategories(subscription.categories) + const columnOrder: GridColumnOrder[] = [ + {key: 'product', name: t('Product'), width: 400}, + {key: 'currentUsage', name: t('Current usage')}, + {key: 'reservedUsage', name: t('Reserved usage')}, + {key: 'reservedSpend', name: t('Reserved spend')}, + { + key: 'budgetSpend', + name: t('%s spend', displayBudgetName(subscription.planDetails, {title: true})), + }, + { + key: 'cta', + name: '', + }, + ]; + + 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; @@ -374,7 +235,8 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { }); const reserved = metricHistory.reserved ?? 0; const free = metricHistory.free; - const usage = metricHistory.usage; + const total = metricHistory.usage; + const paygTotal = metricHistory.onDemandSpendUsed; const activeProductTrial = getActiveProductTrial( subscription.productTrials ?? [], category @@ -399,27 +261,30 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { buckets: subscription.planDetails.planCategories[category], }); const recurringReservedSpend = bucket.price ?? 0; + const reservedTotal = (reserved ?? 0) + (free ?? 0); + const percentUsed = reservedTotal + ? getPercentage(total, reservedTotal) + : undefined; - return ( - - ); - })} - {Object.entries(subscription.addOns ?? {}) + 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 || @@ -430,7 +295,7 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { allowInnerUpperCase: true, }); - const paygUsed = addOnInfo.dataCategories.reduce((acc, category) => { + const paygTotal = addOnInfo.dataCategories.reduce((acc, category) => { return acc + (subscription.categories[category]?.onDemandSpendUsed ?? 0); }, 0); const addOnDataCategories = addOnInfo.dataCategories; @@ -442,7 +307,9 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { budget => (budget.apiName as string) === reservedBudgetCategory ) : undefined; - + const percentUsed = reservedBudget?.totalReservedSpend + ? getPercentage(paygTotal, reservedBudget?.totalReservedSpend) + : undefined; const activeProductTrial = getActiveProductTrial( subscription.productTrials ?? [], addOnDataCategories[0] as DataCategory @@ -451,10 +318,10 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { subscription.productTrials ?? [], addOnDataCategories[0] as DataCategory ); - const isEnabled = addOnInfo.enabled; + const hasAccess = addOnInfo.enabled; // TODO(isabella): fix this for add-ons with multiple data categories - const bucket = isEnabled + const bucket = hasAccess ? getBucket({ buckets: subscription.planDetails.planCategories[ @@ -465,82 +332,293 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { : undefined; const recurringReservedSpend = bucket?.price ?? 0; - if (!isEnabled) { - ; - } - - return ( - - - setOpenState({...openState, [apiName]: !openState[apiName]}) + 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, + }, + }; + }), + ...Object.entries(subscription.addOns ?? {}) + .filter( + ([apiName, addOnInfo]) => + (!addOnInfo.billingFlag || + organization.features.includes(addOnInfo.billingFlag)) && + addOnInfo.enabled && + openState[apiName] + ) + .flatMap(([apiName, addOnInfo]) => { + const reservedBudgetCategory = getReservedBudgetCategoryForAddOn( + apiName as AddOnCategory + ); + const reservedBudget = reservedBudgetCategory + ? subscription.reservedBudgets?.find( + budget => (budget.apiName as string) === reservedBudgetCategory + ) + : undefined; + return addOnInfo.dataCategories.map(addOnDataCategory => { + const reservedBudgetSpend = + reservedBudget?.categories[addOnDataCategory]?.reservedSpend ?? 0; + const paygTotal = + subscription.categories[addOnDataCategory]?.onDemandSpendUsed ?? 0; + const productName = 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: paygTotal, + currentUsage: (reservedBudgetSpend ?? 0) + paygTotal, + product: productName, + }, + }; + }); + }), + ]; + }, [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} + + ); } - hasToggle - reserved={reservedBudget?.reservedBudget} - free={reservedBudget?.freeBudget} - total={reservedBudget?.totalReservedSpend ?? 0} - paygTotal={paygUsed} - hasAccess={isEnabled} - productName={addOnName} - isTopMostProduct - productTrial={activeProductTrial ?? potentialProductTrial ?? undefined} - /> - {isEnabled && openState[apiName] && ( - - {Object.entries(subscription.categories) - .filter(([category]) => - addOnDataCategories.includes(category as DataCategory) - ) - .map(([category, categoryDetails]) => { - const productName = getPlanCategoryName({ - plan: subscription.planDetails, - category: category as DataCategory, - title: true, - }); - const spend = - reservedBudget?.categories[category as DataCategory] - ?.reservedSpend ?? 0; - - return ( - + {formattedCurrentUsage}{' '} + {!isPaygOnly && ( + - ); - })} - - )} - - ); - })} + )} + + ); + } + 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
; + break; + } + 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 ( + + { + // setTrialButtonBusy(true); + // }} + // onTrialStarted={() => { + // setTrialButtonBusy(true); + // }} + // onTrialFailed={() => { + // setTrialButtonBusy(false); + // }} + // busy={trialButtonBusy} + // disabled={trialButtonBusy} + size="xs" + > + + + + {/* {isScreenSmall ? t('Start trial') : */} + {t('Start 14 day free trial')} + {/* } */} + + + + + ); + } + return
; + break; + } + default: + return row[column.key as keyof typeof row]; + } + }, + }} + /> + ); } From 6b0f0f1ba79daaa5e29f74d5c9fb5f53d18bdbf9 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Tue, 7 Oct 2025 17:39:26 -0400 Subject: [PATCH 08/17] initial cleanup --- .../views/subscriptionPage/usageOverview.tsx | 122 ++++-------------- 1 file changed, 28 insertions(+), 94 deletions(-) diff --git a/static/gsApp/views/subscriptionPage/usageOverview.tsx b/static/gsApp/views/subscriptionPage/usageOverview.tsx index 566c519603ea2c..6f992dd3540e0a 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview.tsx @@ -30,7 +30,6 @@ import {useCurrentBillingHistory} from 'getsentry/hooks/useCurrentBillingHistory import { AddOnCategory, OnDemandBudgetMode, - type Plan, type ProductTrial, type Subscription, } from 'getsentry/types'; @@ -51,87 +50,6 @@ interface UsageOverviewProps { subscription: Subscription; } -type ToggleableProductRowProps = { - ariaLabel: string; - hasToggle: true; - isOpen: boolean; - onToggle: () => void; -}; - -type NonToggleableProductRowProps = { - hasToggle: false; - ariaLabel?: never; - isOpen?: never; - onToggle?: never; -}; - -type DataCategoryProductRowProps = { - dataCategory: DataCategory; - addOnCategory?: never; -}; - -type AddOnCategoryProductRowProps = { - addOnCategory: AddOnCategory; - dataCategory?: never; -}; - -type ProductRowProps = (ToggleableProductRowProps | NonToggleableProductRowProps) & - (DataCategoryProductRowProps | AddOnCategoryProductRowProps) & { - /** - * Whether the customer has access to the product - */ - hasAccess: boolean; - organization: Organization; - /** - * PAYG usage, in cents - */ - paygTotal: number; - plan: Plan; - /** - * The display name for the product - */ - productName: string; - /** - * Total reserved, in events for volume-based or cents for budget-based - */ - total: number; - /** - * Gifted total, in events for volume-based or cents for budget-based - */ - free?: number; - /** - * Whether the product is the top-most in its hierarchy - */ - isTopMostProduct?: boolean; - /** - * The active product trial for the product, if available, otherwise the potential product trial, if available - */ - productTrial?: ProductTrial; - /** - * The recurring cost for the reserved volume or budget for the product - */ - recurringReservedSpend?: number; - /** - * Reserved total, in events for volume-based or cents for budget-based - * This is 0 for pay-as-you-go only products - */ - reserved?: number; - }; - -// the breakpoints at which we condense and expand the usage overview table -// const MAX_BREAKPOINT_TO_CONDENSE = 'md'; -// const MIN_BREAKPOINT_TO_EXPAND = 'lg'; - -const GRID_PROPS = { - columns: '2fr repeat(5, 1fr)', - padding: 'lg xl' as const, - borderTop: 'primary' as const, - gap: '2xl' as const, - align: 'center' as const, - overflowX: 'scroll' as const, - whiteSpace: 'nowrap' as const, -}; - function CurrencyCell({ children, bold, @@ -164,6 +82,9 @@ function ReservedUsageBar({percentUsed}: {percentUsed: number}) { function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { const [openState, setOpenState] = useState>({}); + const [trialButtonBusyState, setTrialButtonBusyState] = useState< + Partial> + >({}); useEffect(() => { Object.entries(subscription.addOns ?? {}) @@ -200,6 +121,7 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { }, ]; + // TODO(isabella): refactor this to have better types + be more efficient const productData: Array<{ attrs: { hasAccess: boolean; @@ -308,7 +230,10 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { ) : undefined; const percentUsed = reservedBudget?.totalReservedSpend - ? getPercentage(paygTotal, reservedBudget?.totalReservedSpend) + ? getPercentage( + reservedBudget?.totalReservedSpend, + reservedBudget?.reservedBudget + ) : undefined; const activeProductTrial = getActiveProductTrial( subscription.productTrials ?? [], @@ -584,17 +509,26 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { }} aria-label={t('Start 14 day free %s trial', row.product)} priority="primary" - // handleClick={() => { - // setTrialButtonBusy(true); - // }} - // onTrialStarted={() => { - // setTrialButtonBusy(true); - // }} - // onTrialFailed={() => { - // setTrialButtonBusy(false); - // }} - // busy={trialButtonBusy} - // disabled={trialButtonBusy} + handleClick={() => { + 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" > From 159f33c3833800fca27173323ed35aa09a4561c1 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Wed, 8 Oct 2025 12:53:02 -0400 Subject: [PATCH 09/17] handle resizing + show non-billing --- .../gsApp/views/subscriptionPage/overview.tsx | 14 +- .../views/subscriptionPage/usageOverview.tsx | 526 +++++++++--------- 2 files changed, 275 insertions(+), 265 deletions(-) diff --git a/static/gsApp/views/subscriptionPage/overview.tsx b/static/gsApp/views/subscriptionPage/overview.tsx index 457d7e2f795016..c7db4a169eadd9 100644 --- a/static/gsApp/views/subscriptionPage/overview.tsx +++ b/static/gsApp/views/subscriptionPage/overview.tsx @@ -367,11 +367,11 @@ function Overview({location, subscription, promotionData}: Props) { + {isNewBillingUI ? ( ) : ( - - )} + ); } @@ -392,8 +392,14 @@ function Overview({location, subscription, promotionData}: Props) { - {renderUsageChart(usageData)} - {renderUsageCards(usageData)} + {isNewBillingUI ? ( + + ) : ( + + {renderUsageChart(usageData)} + {renderUsageCards(usageData)} + + )} ); diff --git a/static/gsApp/views/subscriptionPage/usageOverview.tsx b/static/gsApp/views/subscriptionPage/usageOverview.tsx index 6f992dd3540e0a..c2145a7cae96d7 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview.tsx @@ -1,4 +1,4 @@ -import React, {Fragment, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useMemo, useState} from 'react'; import styled from '@emotion/styled'; import {Tag} from 'sentry/components/core/badge/tag'; @@ -22,6 +22,7 @@ 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'; @@ -81,6 +82,7 @@ function ReservedUsageBar({percentUsed}: {percentUsed: number}) { } function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { + const hasBillingPerms = organization.features.includes('billing'); const [openState, setOpenState] = useState>({}); const [trialButtonBusyState, setTrialButtonBusyState] = useState< Partial> @@ -101,25 +103,24 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { subscription.planDetails.addOnCategories ).flatMap(addOn => addOn.dataCategories); - const tableHeaderProps = { - variant: 'muted' as const, - bold: true, - }; - - const columnOrder: GridColumnOrder[] = [ - {key: 'product', name: t('Product'), width: 400}, - {key: 'currentUsage', name: t('Current usage')}, - {key: 'reservedUsage', name: t('Reserved usage')}, - {key: 'reservedSpend', name: t('Reserved spend')}, - { - key: 'budgetSpend', - name: t('%s spend', displayBudgetName(subscription.planDetails, {title: true})), - }, - { - key: 'cta', - name: '', - }, - ]; + const columnOrder: GridColumnOrder[] = useMemo(() => { + 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: 50}, + { + key: 'budgetSpend', + name: t('%s spend', displayBudgetName(subscription.planDetails, {title: true})), + width: 50, + }, + { + key: 'cta', + name: '', + width: 50, + }, + ].filter(column => hasBillingPerms || !column.key.endsWith('Spend')); + }, [hasBillingPerms, subscription.planDetails]); // TODO(isabella): refactor this to have better types + be more efficient const productData: Array<{ @@ -330,237 +331,238 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { }, [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; + 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; - } + if (defined(isOpen) && !isOpen && isChildProduct) { + return null; + } - switch (column.key) { - case 'product': { - const {hasToggle, onToggle, ariaLabel, productTrial} = attrs; - const title = ( - - - {!hasAccess && } - - {' '} - {row.product} - - - {productTrial && } - - ); + 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 ( - + if (hasToggle) { + return ( + + + ) : ( + + ) + } + aria-label={ariaLabel} + onClick={() => onToggle?.()} + > {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 - ? formattedTotal - : `${formattedTotal} / ${formattedReservedTotal}`; - return ( - - {formattedCurrentUsage}{' '} - {!isPaygOnly && ( - - )} - - ); - } - 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
; - break; - } - 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" - > - - - - {/* {isScreenSmall ? t('Start trial') : */} - {t('Start 14 day free trial')} - {/* } */} - - - + + + ); + } + 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 + ? formattedTotal + : `${formattedTotal} / ${formattedReservedTotal}`; + return ( + + {formattedCurrentUsage}{' '} + {!isPaygOnly && ( + + )} + + ); + } + 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
; + break; + } + 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" + > + + + + {/* {isScreenSmall ? t('Start trial') : */} + {t('Start 14 day free trial')} + {/* } */} + - ); - } - return
; - break; - } - default: - return row[column.key as keyof typeof row]; + + + ); } - }, - }} - /> - - + return
; + break; + } + default: + return row[column.key as keyof typeof row]; + } + }, + }} + /> ); } function UsageOverview({subscription, organization}: UsageOverviewProps) { + const hasBillingPerms = organization.features.includes('billing'); + const {isCollapsed: navIsCollapsed} = useNavContext(); const {currentHistory, isPending, isError} = useCurrentBillingHistory(); return ( - + {t('Usage Overview')} - - } - aria-label={t('View usage history')} - priority="link" - to="/settings/billing/usage/" - > - {t('View usage history')} - - - + {hasBillingPerms && ( + + } + aria-label={t('View usage history')} + priority="link" + to="/settings/billing/usage/" + > + {t('View usage history')} + + + + )} From a18a6c1de83ed59b5b8ef3312c63fa84df9e6a48 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Wed, 8 Oct 2025 13:20:45 -0400 Subject: [PATCH 10/17] tests + billing perms --- .../subscriptionPage/usageOverview.spec.tsx | 119 ++++++++++++++++++ .../views/subscriptionPage/usageOverview.tsx | 28 +++-- .../subscriptionPage/usageTotals.spec.tsx | 2 +- 3 files changed, 138 insertions(+), 11 deletions(-) create mode 100644 static/gsApp/views/subscriptionPage/usageOverview.spec.tsx 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 index c2145a7cae96d7..825bd665bff92e 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview.tsx @@ -82,7 +82,7 @@ function ReservedUsageBar({percentUsed}: {percentUsed: number}) { } function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { - const hasBillingPerms = organization.features.includes('billing'); + const hasBillingPerms = organization.access.includes('org:billing'); const [openState, setOpenState] = useState>({}); const [trialButtonBusyState, setTrialButtonBusyState] = useState< Partial> @@ -104,6 +104,9 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { ).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}, @@ -119,8 +122,12 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { name: '', width: 50, }, - ].filter(column => hasBillingPerms || !column.key.endsWith('Spend')); - }, [hasBillingPerms, subscription.planDetails]); + ].filter( + column => + (hasBillingPerms || !column.key.endsWith('Spend')) && + (hasAnyPotentialProductTrial || column.key !== 'cta') + ); + }, [hasBillingPerms, subscription.planDetails, subscription.productTrials]); // TODO(isabella): refactor this to have better types + be more efficient const productData: Array<{ @@ -428,20 +435,21 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { useUnitScaling: true, }) : displayPriceWithCents({cents: (reserved ?? 0) + (free ?? 0)}); - const formattedCurrentUsage = isPaygOnly - ? formattedTotal - : `${formattedTotal} / ${formattedReservedTotal}`; + const formattedCurrentUsage = + isPaygOnly || isChildProduct + ? formattedTotal + : `${formattedTotal} / ${formattedReservedTotal}`; return ( {formattedCurrentUsage}{' '} - {!isPaygOnly && ( + {!(isPaygOnly || isChildProduct) && ( @@ -546,7 +554,7 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { } function UsageOverview({subscription, organization}: UsageOverviewProps) { - const hasBillingPerms = organization.features.includes('billing'); + const hasBillingPerms = organization.access.includes('org:billing'); const {isCollapsed: navIsCollapsed} = useNavContext(); const {currentHistory, isPending, isError} = useCurrentBillingHistory(); return ( 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(); }); From 6270a23a4f7d4477114ca86d7e20b4ba4ff8cb45 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Wed, 8 Oct 2025 13:22:22 -0400 Subject: [PATCH 11/17] adjust initial columns widths --- static/gsApp/views/subscriptionPage/usageOverview.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/gsApp/views/subscriptionPage/usageOverview.tsx b/static/gsApp/views/subscriptionPage/usageOverview.tsx index 825bd665bff92e..29933dd127fd9e 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview.tsx @@ -111,11 +111,11 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { {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: 50}, + {key: 'reservedSpend', name: t('Reserved spend'), width: 200}, { key: 'budgetSpend', name: t('%s spend', displayBudgetName(subscription.planDetails, {title: true})), - width: 50, + width: 200, }, { key: 'cta', From eeebb88a766b56f0e62d5cbe4e4104f3563415cc Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Wed, 8 Oct 2025 13:25:34 -0400 Subject: [PATCH 12/17] fix bad merge --- static/gsApp/views/subscriptionPage/overview.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/static/gsApp/views/subscriptionPage/overview.tsx b/static/gsApp/views/subscriptionPage/overview.tsx index ed99feb5ba338e..85ea883c5b837b 100644 --- a/static/gsApp/views/subscriptionPage/overview.tsx +++ b/static/gsApp/views/subscriptionPage/overview.tsx @@ -63,7 +63,6 @@ function Overview({location, subscription, promotionData}: Props) { const organization = useOrganization(); const isNewBillingUI = hasNewBillingUI(organization); const navigate = useNavigate(); - const isNewBillingUI = hasNewBillingUI(organization); const displayMode = ['cost', 'usage'].includes(location.query.displayMode as string) ? (location.query.displayMode as 'cost' | 'usage') From 759b936137c588d23f8163bf0c00b4e4b62e01a1 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Wed, 8 Oct 2025 13:34:44 -0400 Subject: [PATCH 13/17] improve memoized value + change stale time on history --- .../gsApp/hooks/useCurrentBillingHistory.tsx | 2 +- .../views/subscriptionPage/usageOverview.tsx | 114 ++++++++---------- 2 files changed, 52 insertions(+), 64 deletions(-) diff --git a/static/gsApp/hooks/useCurrentBillingHistory.tsx b/static/gsApp/hooks/useCurrentBillingHistory.tsx index 9500751dc00758..bff97880d2b65e 100644 --- a/static/gsApp/hooks/useCurrentBillingHistory.tsx +++ b/static/gsApp/hooks/useCurrentBillingHistory.tsx @@ -13,7 +13,7 @@ export function useCurrentBillingHistory() { isPending, isError, } = useApiQuery([`/customers/${organization.slug}/history/`], { - staleTime: Infinity, // this changes once a month, so we can cache for a long time + staleTime: 0, // TODO(billing): Create an endpoint that returns the current history }); const currentHistory: BillingHistory | null = useMemo(() => { diff --git a/static/gsApp/views/subscriptionPage/usageOverview.tsx b/static/gsApp/views/subscriptionPage/usageOverview.tsx index 29933dd127fd9e..26734d5869f34f 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview.tsx @@ -129,7 +129,7 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { ); }, [hasBillingPerms, subscription.planDetails, subscription.productTrials]); - // TODO(isabella): refactor this to have better types + be more efficient + // TODO(isabella): refactor this to have better types const productData: Array<{ attrs: { hasAccess: boolean; @@ -220,7 +220,7 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { !addOnInfo.billingFlag || organization.features.includes(addOnInfo.billingFlag) ) - .map(([apiName, addOnInfo]) => { + .flatMap(([apiName, addOnInfo]) => { const addOnName = toTitleCase(addOnInfo.productName, { allowInnerUpperCase: true, }); @@ -265,74 +265,62 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { : undefined; const recurringReservedSpend = bucket?.price ?? 0; - 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, - }, - }; - }), - ...Object.entries(subscription.addOns ?? {}) - .filter( - ([apiName, addOnInfo]) => - (!addOnInfo.billingFlag || - organization.features.includes(addOnInfo.billingFlag)) && - addOnInfo.enabled && - openState[apiName] - ) - .flatMap(([apiName, addOnInfo]) => { - const reservedBudgetCategory = getReservedBudgetCategoryForAddOn( - apiName as AddOnCategory - ); - const reservedBudget = reservedBudgetCategory - ? subscription.reservedBudgets?.find( - budget => (budget.apiName as string) === reservedBudgetCategory - ) - : undefined; - return addOnInfo.dataCategories.map(addOnDataCategory => { - const reservedBudgetSpend = - reservedBudget?.categories[addOnDataCategory]?.reservedSpend ?? 0; - const paygTotal = - subscription.categories[addOnDataCategory]?.onDemandSpendUsed ?? 0; - const productName = getPlanCategoryName({ - plan: subscription.planDetails, - category: addOnDataCategory, - title: true, - }); - return { + 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, - isChildProduct: true, + hasAccess, + free: reservedBudget?.freeBudget ?? 0, + reserved: reservedBudget?.reservedBudget ?? 0, + isPaygOnly: !reservedBudget, + productTrial: activeProductTrial ?? potentialProductTrial ?? undefined, + hasToggle: true, isOpen: openState[apiName], - hasAccess: true, - isPaygOnly: false, + 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, - currentUsage: (reservedBudgetSpend ?? 0) + paygTotal, - product: productName, }, - }; - }); + }, + ...(childCategoriesData ?? []), + ]; }), ]; }, [subscription, allAddOnDataCategories, organization.features, openState]); From 63eec5bd1129a6d1f431a2e9e7bc92e93d182b6c Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Wed, 8 Oct 2025 13:37:06 -0400 Subject: [PATCH 14/17] lol --- static/gsApp/views/subscriptionPage/usageOverview.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/static/gsApp/views/subscriptionPage/usageOverview.tsx b/static/gsApp/views/subscriptionPage/usageOverview.tsx index 26734d5869f34f..1d5d09ffb0b627 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview.tsx @@ -519,11 +519,7 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { > - - {/* {isScreenSmall ? t('Start trial') : */} - {t('Start 14 day free trial')} - {/* } */} - + {t('Start 14 day free trial')} From 08fb01ac0c05461dfea39b2bbc554d0ea7733298 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Wed, 8 Oct 2025 13:37:51 -0400 Subject: [PATCH 15/17] rm dead code --- static/gsApp/views/subscriptionPage/usageOverview.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/static/gsApp/views/subscriptionPage/usageOverview.tsx b/static/gsApp/views/subscriptionPage/usageOverview.tsx index 1d5d09ffb0b627..9eb1b985af1d67 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview.tsx @@ -469,7 +469,6 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { ); } return
; - break; } case 'reservedSpend': case 'budgetSpend': { @@ -526,7 +525,6 @@ function UsageOverviewTable({subscription, organization}: UsageOverviewProps) { ); } return
; - break; } default: return row[column.key as keyof typeof row]; From fbb130f980cb41c617226833b2a60fd36b0c98f1 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Wed, 8 Oct 2025 16:17:55 -0400 Subject: [PATCH 16/17] fix test --- static/gsApp/views/subscriptionPage/overview.spec.tsx | 5 +++++ 1 file changed, 5 insertions(+) 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, {}); }); From 4d908f73ceb9e9698d9409588bbfcbbf64179213 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Wed, 8 Oct 2025 16:18:56 -0400 Subject: [PATCH 17/17] mb --- static/gsApp/components/productTrial/productTrialTag.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/gsApp/components/productTrial/productTrialTag.tsx b/static/gsApp/components/productTrial/productTrialTag.tsx index 2f33db4562ebf3..4b48b683f85b5b 100644 --- a/static/gsApp/components/productTrial/productTrialTag.tsx +++ b/static/gsApp/components/productTrial/productTrialTag.tsx @@ -39,7 +39,7 @@ function ProductTrialTag({trial, type, showTrialEnded = false}: ProductTrialTagP } const daysLeft = -1 * getDaysSinceDate(trial.endDate ?? ''); - const tagType = type ?? (daysLeft <= 14 ? 'warning' : 'highlight'); + const tagType = type ?? (daysLeft <= 7 ? 'warning' : 'highlight'); return ( } type={tagType}>