From 7eebccc86d47de248756a2c94e7978e420f5de91 Mon Sep 17 00:00:00 2001 From: Jarrett Scott Date: Thu, 15 May 2025 14:29:09 -0700 Subject: [PATCH 1/9] add Seer to CheckoutAPIData --- static/gsApp/views/amCheckout/types.tsx | 2 ++ static/gsApp/views/amCheckout/utils.tsx | 3 +++ 2 files changed, 5 insertions(+) diff --git a/static/gsApp/views/amCheckout/types.tsx b/static/gsApp/views/amCheckout/types.tsx index 312b129771ba6d..c1be400a99aa55 100644 --- a/static/gsApp/views/amCheckout/types.tsx +++ b/static/gsApp/views/amCheckout/types.tsx @@ -16,6 +16,7 @@ type BaseCheckoutData = { applyNow?: boolean; onDemandBudget?: OnDemandBudgets; onDemandMaxSpend?: number; + seerEnabled?: boolean; }; export type CheckoutFormData = BaseCheckoutData & { @@ -35,6 +36,7 @@ export type CheckoutAPIData = BaseCheckoutData & { reservedSpans?: number; reservedTransactions?: number; reservedUptime?: number; + seer?: boolean; }; export type StepProps = { diff --git a/static/gsApp/views/amCheckout/utils.tsx b/static/gsApp/views/amCheckout/utils.tsx index a779075716335c..3774c726ee3723 100644 --- a/static/gsApp/views/amCheckout/utils.tsx +++ b/static/gsApp/views/amCheckout/utils.tsx @@ -466,6 +466,9 @@ export function getCheckoutAPIData({ referrer: referrer || 'billing', ...(previewToken && {previewToken}), ...(paymentIntent && {paymentIntent}), + ...(formData.seerEnabled && { + seer: formData.seerEnabled, + }), }; if (formData.applyNow) { From 7dd38a10525bb3b530c55d5d1753b7d19025c665 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Fri, 16 May 2025 14:43:47 -0400 Subject: [PATCH 2/9] feat(seer): revamped checkout --- static/gsApp/types/index.tsx | 4 + .../views/amCheckout/checkoutOverview.tsx | 84 +++--- .../views/amCheckout/checkoutOverviewV2.tsx | 206 ++++++++++----- static/gsApp/views/amCheckout/index.tsx | 32 ++- .../views/amCheckout/steps/contractSelect.tsx | 1 + .../views/amCheckout/steps/planSelect.tsx | 219 +++++++--------- .../views/amCheckout/steps/productSelect.tsx | 245 ++++++++++++++++++ static/gsApp/views/amCheckout/types.tsx | 16 +- static/gsApp/views/amCheckout/utils.tsx | 54 ++-- 9 files changed, 597 insertions(+), 264 deletions(-) create mode 100644 static/gsApp/views/amCheckout/steps/productSelect.tsx diff --git a/static/gsApp/types/index.tsx b/static/gsApp/types/index.tsx index e9708c3fb5f365..f3014f00bb40ac 100644 --- a/static/gsApp/types/index.tsx +++ b/static/gsApp/types/index.tsx @@ -84,6 +84,10 @@ export type ReservedBudgetCategory = { * The API name of the budget */ apiName: ReservedBudgetCategoryType; + /** + * The feature flag determining if the product is available for billing + */ + billingFlag: string | null; /** * Backend name of the category (all caps, snake case) */ diff --git a/static/gsApp/views/amCheckout/checkoutOverview.tsx b/static/gsApp/views/amCheckout/checkoutOverview.tsx index 354cf965e9bbbb..8ff56f81f85ae8 100644 --- a/static/gsApp/views/amCheckout/checkoutOverview.tsx +++ b/static/gsApp/views/amCheckout/checkoutOverview.tsx @@ -8,7 +8,7 @@ import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types/organization'; -import {ANNUAL, MONTHLY, SEER_MONTHLY_PRICE_CENTS} from 'getsentry/constants'; +import {ANNUAL, MONTHLY} from 'getsentry/constants'; import type {BillingConfig, Plan, Promotion, Subscription} from 'getsentry/types'; import {OnDemandBudgetMode} from 'getsentry/types'; import {formatReservedWithUnits} from 'getsentry/utils/billing'; @@ -174,46 +174,46 @@ class CheckoutOverview extends Component { ); } - renderAdditionalFeature({ - featureKey, - title, - description, - priceCents, - enabledField, - }: { - description: string; - enabledField: string; - featureKey: string; - priceCents: number; - title: string; - }) { - const {formData} = this.props; - const isEnabled = formData[enabledField as keyof CheckoutFormData]; - - if (!isEnabled) { - return null; - } - - return ( - -
- {title} - {description} -
- {`${utils.displayPrice({cents: priceCents})}/mo`} -
- ); - } - - renderSeer() { - return this.renderAdditionalFeature({ - featureKey: 'seer', - title: t('Seer: Sentry AI Enhancements'), - description: t('Surface insights and propose solutions to fix bugs faster.'), - priceCents: SEER_MONTHLY_PRICE_CENTS, - enabledField: 'seerEnabled', - }); - } + // renderAdditionalFeature({ + // featureKey, + // title, + // description, + // priceCents, + // enabledField, + // }: { + // description: string; + // enabledField: string; + // featureKey: string; + // priceCents: number; + // title: string; + // }) { + // const {formData} = this.props; + // const isEnabled = formData[enabledField as keyof CheckoutFormData]; + + // if (!isEnabled) { + // return null; + // } + + // return ( + // + //
+ // {title} + // {description} + //
+ // {`${utils.displayPrice({cents: priceCents})}/mo`} + //
+ // ); + // } + + // renderSeer() { + // return this.renderAdditionalFeature({ + // featureKey: 'seer', + // title: t('Seer: Sentry AI Enhancements'), + // description: t('Surface insights and propose solutions to fix bugs faster.'), + // priceCents: SEER_MONTHLY_PRICE_CENTS, + // enabledField: 'seerEnabled', + // }); + // } renderDetailItems = () => { const {activePlan, discountInfo} = this.props; @@ -265,7 +265,7 @@ class CheckoutOverview extends Component { {this.renderDataOptions()} - {this.renderSeer()} + {/* {this.renderSeer()} */} {this.renderOnDemand()} ); diff --git a/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx b/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx index edb6c76435e087..073c3bb250b476 100644 --- a/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx +++ b/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx @@ -10,16 +10,17 @@ import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {DataCategory} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; +import {toTitleCase} from 'sentry/utils/string/toTitleCase'; import { PAYG_BUSINESS_DEFAULT, PAYG_TEAM_DEFAULT, - SEER_MONTHLY_PRICE_CENTS, + RESERVED_BUDGET_QUOTA, } from 'getsentry/constants'; import type {BillingConfig, Plan, Promotion, Subscription} from 'getsentry/types'; import {formatReservedWithUnits, isBizPlanFamily} from 'getsentry/utils/billing'; import {getPlanCategoryName, getSingularCategoryName} from 'getsentry/utils/dataCategory'; -import type {CheckoutFormData} from 'getsentry/views/amCheckout/types'; +import type {CheckoutFormData, SelectableProduct} from 'getsentry/views/amCheckout/types'; import * as utils from 'getsentry/views/amCheckout/utils'; type Props = { @@ -32,12 +33,7 @@ type Props = { discountInfo?: Promotion['discountInfo']; }; -function CheckoutOverviewV2({ - activePlan, - formData, - onUpdate: _onUpdate, - organization, -}: Props) { +function CheckoutOverviewV2({activePlan, formData, onUpdate: _onUpdate}: Props) { const shortInterval = useMemo(() => { return utils.getShortInterval(activePlan.billingInterval); }, [activePlan.billingInterval]); @@ -54,8 +50,8 @@ function CheckoutOverviewV2({ [formData.onDemandMaxSpend] ); - const hasSeerEnabled = !!formData.seerEnabled; - const hasSeerFeature = organization.features.includes('seer-billing'); + // const hasSeerEnabled = !!formData.seerEnabled; + // const hasSeerFeature = organization.features.includes('seer-billing'); const renderPlanDetails = () => { return ( @@ -80,55 +76,55 @@ function CheckoutOverviewV2({ ); }; - const renderAdditionalFeatureSummary = ({ - featureKey, - featureEnabled, - featureAvailable, - title, - tooltipTitle, - priceCents, - }: { - featureAvailable: boolean; - featureEnabled: boolean; - featureKey: string; - priceCents: number; - title: string; - tooltipTitle: string; - }) => { - return ( - featureAvailable && - featureEnabled && ( - - - -
- - {title} -    - <QuestionTooltip size="xs" title={tooltipTitle} /> - -
-
- - {`+${utils.displayPrice({cents: priceCents})}/mo`} - Additional usage billed separately - -
-
- ) - ); - }; - - const renderSeerSummary = () => { - return renderAdditionalFeatureSummary({ - featureKey: 'seer', - featureEnabled: hasSeerEnabled, - featureAvailable: hasSeerFeature, - title: t('Sentry AI Agent'), - tooltipTitle: t('Additional Seer information.'), - priceCents: SEER_MONTHLY_PRICE_CENTS, - }); - }; + // const renderAdditionalFeatureSummary = ({ + // featureKey, + // featureEnabled, + // featureAvailable, + // title, + // tooltipTitle, + // priceCents, + // }: { + // featureAvailable: boolean; + // featureEnabled: boolean; + // featureKey: string; + // priceCents: number; + // title: string; + // tooltipTitle: string; + // }) => { + // return ( + // featureAvailable && + // featureEnabled && ( + // + // + // + //
+ // + // {title} + //    + // <QuestionTooltip size="xs" title={tooltipTitle} /> + // + //
+ //
+ // + // {`+${utils.displayPrice({cents: priceCents})}/mo`} + // Additional usage billed separately + // + //
+ //
+ // ) + // ); + // }; + + // const renderSeerSummary = () => { + // return renderAdditionalFeatureSummary({ + // featureKey: 'seer', + // featureEnabled: hasSeerEnabled, + // featureAvailable: hasSeerFeature, + // title: t('Sentry AI Agent'), + // tooltipTitle: t('Additional Seer information.'), + // priceCents: SEER_MONTHLY_PRICE_CENTS, + // }); + // }; const renderPayAsYouGoBudget = (paygBudgetTotal: number) => { return ( @@ -171,6 +167,77 @@ function CheckoutOverviewV2({ }; const renderProductBreakdown = () => { + const hasAtLeastOneSelectedProduct = Object.values( + activePlan.availableReservedBudgetTypes + ).some(budgetTypeInfo => { + return formData.selectedProducts[ + budgetTypeInfo.apiName as string as SelectableProduct + ]?.enabled; + }); + + if (!hasAtLeastOneSelectedProduct) { + return null; + } + + return ( + + +
+ + {Object.values(activePlan.availableReservedBudgetTypes).map( + budgetTypeInfo => { + const formDataForProduct = + formData.selectedProducts[ + budgetTypeInfo.apiName as string as SelectableProduct + ]; + if (!formDataForProduct) { + return null; + } + + if (formDataForProduct.enabled) { + return ( + + + {toTitleCase(budgetTypeInfo.productName)} + + + + {utils.displayPrice({ + cents: budgetTypeInfo.dataCategories.reduce( + (acc, dataCategory) => { + const bucket = utils.getBucket({ + events: RESERVED_BUDGET_QUOTA, + buckets: activePlan.planCategories[dataCategory], + }); + return acc + bucket.price; + }, + 0 + ), + })} + /{shortInterval} + + + ); + } + return null; + } + )} + +
+
+ ); + }; + + const renderObservabilityProductBreakdown = () => { const paygCategories = [ DataCategory.MONITOR_SEATS, DataCategory.PROFILE_DURATION, @@ -178,12 +245,19 @@ function CheckoutOverviewV2({ DataCategory.UPTIME, ]; + const budgetCategories = Object.values( + activePlan.availableReservedBudgetTypes + ).reduce((acc, type) => { + acc.push(...type.dataCategories); + return acc; + }, [] as DataCategory[]); + return (
{t('All Sentry Products')} {activePlan.categories - .filter(category => activePlan.planCategories[category]) + .filter(category => !budgetCategories.includes(category)) .map(category => { const eventBucket = activePlan.planCategories[category] && @@ -325,18 +399,14 @@ function CheckoutOverviewV2({ return ( {renderPlanDetails()} - - {hasSeerEnabled && renderSeerSummary()} + {renderProductBreakdown()} + {/* {hasSeerEnabled && renderSeerSummary()} */} {renderPayAsYouGoBudget(paygMonthlyBudget)} - {renderProductBreakdown()} + {renderObservabilityProductBreakdown()} - {renderTotals( - committedTotal + - (hasSeerFeature && formData.seerEnabled ? SEER_MONTHLY_PRICE_CENTS : 0), - paygMonthlyBudget - )} + {renderTotals(committedTotal, paygMonthlyBudget)} ); } diff --git a/static/gsApp/views/amCheckout/index.tsx b/static/gsApp/views/amCheckout/index.tsx index 5127be2431b3b5..fc38609609acd2 100644 --- a/static/gsApp/views/amCheckout/index.tsx +++ b/static/gsApp/views/amCheckout/index.tsx @@ -70,7 +70,11 @@ import OnDemandSpend from 'getsentry/views/amCheckout/steps/onDemandSpend'; import PlanSelect from 'getsentry/views/amCheckout/steps/planSelect'; import ReviewAndConfirm from 'getsentry/views/amCheckout/steps/reviewAndConfirm'; import SetPayAsYouGo from 'getsentry/views/amCheckout/steps/setPayAsYouGo'; -import type {CheckoutFormData} from 'getsentry/views/amCheckout/types'; +import type { + CheckoutFormData, + SelectedProductData, +} from 'getsentry/views/amCheckout/types'; +import {SelectableProduct} from 'getsentry/views/amCheckout/types'; import {getBucket} from 'getsentry/views/amCheckout/utils'; import { getTotalBudget, @@ -431,7 +435,15 @@ class AMCheckout extends Component { }, ...(onDemandMaxSpend > 0 && {onDemandMaxSpend}), onDemandBudget: parseOnDemandBudgetsFromSubscription(subscription), - seerEnabled: false, + selectedProducts: Object.values(SelectableProduct).reduce( + (acc, product) => { + acc[product] = { + enabled: false, + }; + return acc; + }, + {} as Record + ), }; if ( @@ -448,13 +460,25 @@ class AMCheckout extends Component { }; } + subscription.reservedBudgets?.forEach(budget => { + if ( + Object.values(SelectableProduct).includes( + budget.apiName as string as SelectableProduct + ) + ) { + data.selectedProducts[budget.apiName as string as SelectableProduct] = { + enabled: budget.reservedBudget > 0, + }; + } + }); + return this.getValidData(initialPlan, data); } getValidData(plan: Plan, data: Omit): CheckoutFormData { const {subscription, organization, checkoutTier} = this.props; - const {onDemandMaxSpend, onDemandBudget, seerEnabled} = data; + const {onDemandMaxSpend, onDemandBudget, selectedProducts} = data; // Verify next plan data volumes before updating form data // finds the approximate bucket if event level does not exist @@ -500,7 +524,7 @@ class AMCheckout extends Component { onDemandMaxSpend: newOnDemandMaxSpend, onDemandBudget: newOnDemandBudget, reserved: nextReserved, - seerEnabled, + selectedProducts, }; } diff --git a/static/gsApp/views/amCheckout/steps/contractSelect.tsx b/static/gsApp/views/amCheckout/steps/contractSelect.tsx index 8e82abeada50a8..ec679f2a9591b1 100644 --- a/static/gsApp/views/amCheckout/steps/contractSelect.tsx +++ b/static/gsApp/views/amCheckout/steps/contractSelect.tsx @@ -109,6 +109,7 @@ class ContractSelect extends Component { const price = getReservedTotal({ plan, reserved: formData.reserved, + selectedProducts: formData.selectedProducts, ...discountData, }); diff --git a/static/gsApp/views/amCheckout/steps/planSelect.tsx b/static/gsApp/views/amCheckout/steps/planSelect.tsx index e9b36962bd6bf4..a9550dd4659e26 100644 --- a/static/gsApp/views/amCheckout/steps/planSelect.tsx +++ b/static/gsApp/views/amCheckout/steps/planSelect.tsx @@ -1,23 +1,18 @@ -import {useState} from 'react'; import styled from '@emotion/styled'; import cloneDeep from 'lodash/cloneDeep'; import moment from 'moment-timezone'; -import {SeerIcon} from 'sentry/components/ai/SeerIcon'; -import {FeatureBadge} from 'sentry/components/core/badge/featureBadge'; import {Tag} from 'sentry/components/core/badge/tag'; import {Button} from 'sentry/components/core/button'; import Panel from 'sentry/components/panels/panel'; import PanelBody from 'sentry/components/panels/panelBody'; import PanelFooter from 'sentry/components/panels/panelFooter'; -import QuestionTooltip from 'sentry/components/questionTooltip'; -import {IconAdd, IconCheckmark, IconWarning} from 'sentry/icons'; +import {IconWarning} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import getDaysSinceDate from 'sentry/utils/getDaysSinceDate'; import {Oxfordize} from 'sentry/utils/oxfordizeArray'; -import {SEER_MONTHLY_PRICE_CENTS} from 'getsentry/constants'; import {type Plan, PlanTier} from 'getsentry/types'; import { getBusinessPlanOfTier, @@ -33,10 +28,10 @@ import { import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; import usePromotionTriggerCheck from 'getsentry/utils/usePromotionTriggerCheck'; import PlanSelectRow from 'getsentry/views/amCheckout/steps/planSelectRow'; +import ProductSelect from 'getsentry/views/amCheckout/steps/productSelect'; import StepHeader from 'getsentry/views/amCheckout/steps/stepHeader'; import type {StepProps} from 'getsentry/views/amCheckout/types'; import {formatPrice, getDiscountedPrice} from 'getsentry/views/amCheckout/utils'; -import * as utils from 'getsentry/views/amCheckout/utils'; export type PlanContent = { description: React.ReactNode; @@ -140,10 +135,6 @@ function PlanSelect({ onCompleteStep, }: StepProps) { const {data: promotionData, refetch} = usePromotionTriggerCheck(organization); - - const [seerIsEnabled, setSeerIsEnabled] = useState(Boolean(formData.seerEnabled)); - const hasSeerFeature = organization.features.includes('seer-billing'); - const discountInfo = promotion?.discountInfo; let trailingItems: React.ReactNode = null; if (showSubscriptionDiscount({activePlan, discountInfo}) && discountInfo) { @@ -247,87 +238,87 @@ function PlanSelect({ ); }; - const renderAdditionalFeature = ({ - featureKey, - featureEnabled, - featureAvailable, - title, - description, - icon, - priceCents, - featureItems, - tooltipTitle, - isNew = false, - setFeatureEnabled, - }: { - description: string; - featureAvailable: boolean; - featureEnabled: boolean; - featureItems: string[]; - featureKey: string; - icon: React.ReactNode; - priceCents: number; - setFeatureEnabled: (enabled: boolean) => void; - title: string; - tooltipTitle: string; - isNew?: boolean; - }) => { - return ( - featureAvailable && ( - - - - {icon} - {title} - {isNew && } - - {description} - - {featureItems.map((item, index) => ( - {item} - ))} - - - - -
- Extra usage requires PAYG -
-
-
- ) - ); - }; - - const renderSeer = () => { - return renderAdditionalFeature({ - featureKey: 'seer', - featureEnabled: seerIsEnabled, - featureAvailable: hasSeerFeature, - title: 'Add Seer, our AI Agent', - description: 'Insights and solutions to fix bugs faster', - icon: , - priceCents: SEER_MONTHLY_PRICE_CENTS, - featureItems: ['Root Cause Analysis', 'Autofix PRs', 'AI Issue Priority'], - tooltipTitle: t('Additional Seer information.'), - isNew: true, - setFeatureEnabled: setSeerIsEnabled, - }); - }; + // const renderSeer = () => { + // return renderAdditionalFeature({ + // featureKey: 'seer', + // featureEnabled: seerIsEnabled, + // featureAvailable: hasSeerFeature, + // title: 'Add Seer, our AI Agent', + // description: 'Insights and solutions to fix bugs faster', + // icon: , + // priceCents: SEER_MONTHLY_PRICE_CENTS, + // featureItems: ['Root Cause Analysis', 'Autofix PRs', 'AI Issue Priority'], + // tooltipTitle: t('Additional Seer information.'), + // isNew: true, + // setFeatureEnabled: setSeerIsEnabled, + // }); + // }; + + // const renderAdditionalFeature = ({ + // featureKey, + // featureEnabled, + // featureAvailable, + // title, + // description, + // icon, + // priceCents, + // featureItems, + // tooltipTitle, + // isNew = false, + // setFeatureEnabled, + // }: { + // description: string; + // featureAvailable: boolean; + // featureEnabled: boolean; + // featureItems: string[]; + // featureKey: string; + // icon: React.ReactNode; + // priceCents: number; + // setFeatureEnabled: (enabled: boolean) => void; + // title: string; + // tooltipTitle: string; + // isNew?: boolean; + // }) => { + // return ( + // featureAvailable && ( + // + // + // + // {icon} + // {title} + // {isNew && } + // + // {description} + // + // {featureItems.map((item, index) => ( + // {item} + // ))} + // + // + // + // + //
+ // Extra usage requires PAYG + //
+ //
+ //
+ // ) + // ); + // }; const renderFooter = () => { const bizPlanContent = getContentForPlan('business', checkoutTier); @@ -420,7 +411,14 @@ function PlanSelect({ organization={organization} /> {isActive && renderBody()} - {isActive && renderSeer()} + {isActive && ( + + )} {isActive && renderFooter()} ); @@ -440,38 +438,3 @@ const FooterWarningWrapper = styled('div')` align-items: center; gap: ${space(1)}; `; - -const FeatureHeader = styled('div')` - margin: 0; - gap: ${space(1)}; - display: flex; - align-items: center; - font-weight: 900; - font-size: large; -`; - -const FeatureContainer = styled('div')` - display: flex; - flex-direction: column; - gap: ${space(1.5)}; -`; - -const FeatureDescription = styled('p')` - margin: 0; - color: ${p => p.theme.subText}; -`; - -const FeatureList = styled('ul')` - list-style: disc; - padding-left: ${space(2)}; - margin: ${space(1)} 0 0 0; - color: ${p => p.theme.subText}; -`; - -const FeatureItem = styled('li')` - margin-bottom: ${space(0.5)}; -`; - -const ButtonWrapper = styled('div')` - margin-top: ${space(2)}; -`; diff --git a/static/gsApp/views/amCheckout/steps/productSelect.tsx b/static/gsApp/views/amCheckout/steps/productSelect.tsx new file mode 100644 index 00000000000000..9d051ca2edee54 --- /dev/null +++ b/static/gsApp/views/amCheckout/steps/productSelect.tsx @@ -0,0 +1,245 @@ +import {Fragment} from 'react'; +import {useTheme} from '@emotion/react'; +import styled from '@emotion/styled'; + +import {SeerIcon} from 'sentry/components/ai/SeerIcon'; +import {Button} from 'sentry/components/core/button'; +import PanelItem from 'sentry/components/panels/panelItem'; +import QuestionTooltip from 'sentry/components/questionTooltip'; +import {IconAdd, IconCheckmark} from 'sentry/icons'; +import {t, tct} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import {toTitleCase} from 'sentry/utils/string/toTitleCase'; +import type {Color} from 'sentry/utils/theme'; + +import formatCurrency from 'getsentry/utils/formatCurrency'; +import {SelectableProduct, type StepProps} from 'getsentry/views/amCheckout/types'; +import * as utils from 'getsentry/views/amCheckout/utils'; + +function ProductSelect({ + activePlan, + // billingConfig, + formData, + // onEdit, + onUpdate, + // subscription, + organization, +}: Pick) { + const availableProducts = Object.values(activePlan.availableReservedBudgetTypes) + .filter( + productInfo => + productInfo.isFixed && // NOTE: for now, we only supported fixed budget products in checkout + productInfo.billingFlag && + organization.features.includes(productInfo.billingFlag) + ) + .map(productInfo => { + return productInfo; + }); + + const theme = useTheme(); + const PRODUCT_CHECKOUT_INFO = { + [SelectableProduct.SEER]: { + icon: , + color: theme.pink400 as Color, + gradientEndColor: theme.pink100 as Color, + description: 'Detect and fix issues faster with our AI debugging agent.', + features: ['Issue scan', 'Root cause analysis', 'Solution and code changes'], + }, + }; + const billingInterval = utils.getShortInterval(activePlan.billingInterval); + + return ( + + + {availableProducts.map(productInfo => { + const checkoutInfo = + PRODUCT_CHECKOUT_INFO[productInfo.apiName as string as SelectableProduct]; + if (!checkoutInfo) { + return null; + } + + return ( + + + + + {checkoutInfo.icon} + {toTitleCase(productInfo.productName)} + +
+

{checkoutInfo.description}

+ {checkoutInfo.features.map(feature => ( + + + {feature} + + ))} +
+
+ + + + {t('Extra usage requires PAYG ')} + + + +
+ {/* */} +
+ ); + })} +
+ ); + + // const renderSeer = () => { + // return renderAdditionalFeature({ + // featureKey: 'seer', + // featureEnabled: seerIsEnabled, + // featureAvailable: hasSeerFeature, + // title: 'Add Seer, our AI Agent', + // description: 'Insights and solutions to fix bugs faster', + // icon: , + // priceCents: SEER_MONTHLY_PRICE_CENTS, + // featureItems: ['Root Cause Analysis', 'Autofix PRs', 'AI Issue Priority'], + // tooltipTitle: t('Additional Seer information.'), + // isNew: true, + // setFeatureEnabled: setSeerIsEnabled, + // }); + // }; +} + +export default ProductSelect; + +const Separator = styled('div')` + border-top: 1px solid ${p => p.theme.innerBorder}; + margin: 0; +`; + +const ProductOption = styled(PanelItem)<{isSelected?: boolean}>` + margin: ${space(1.5)}; + padding: 0; + display: inherit; +`; + +const ProductOptionContent = styled('div')<{gradientColor: string}>` + padding: ${space(2)}; + background-color: ${p => p.theme.backgroundSecondary}; + display: flex; + gap: ${space(4)}; + justify-content: space-between; + border: 1px solid ${p => p.theme.innerBorder}; + border-radius: ${p => p.theme.borderRadius}; + + @keyframes gradient { + 0% { + background-position: 0% 50%; + } + 100% { + background-position: 100% 50%; + } + } + + &:hover { + background: linear-gradient( + 0.33turn, + ${p => p.gradientColor}, + ${p => p.theme.background}, + ${p => p.gradientColor}, + ${p => p.theme.background} + ); + background-size: 400% 400%; + animation: gradient 4s ease-in-out infinite; + border-color: ${p => p.gradientColor}; + + button { + border-color: ${p => p.gradientColor}; + } + } +`; + +const Column = styled('div')<{alignItems?: string}>` + display: flex; + flex-direction: column; + gap: ${space(0.75)}; + align-items: ${p => p.alignItems}; +`; + +const ProductLabel = styled('div')<{productColor: string}>` + display: flex; + align-items: center; + flex-direction: row; + flex-wrap: nowrap; + gap: ${space(1)}; + color: ${p => p.productColor}; +`; + +const ProductName = styled('div')` + font-size: ${p => p.theme.fontSizeExtraLarge}; + font-weight: 600; +`; + +const Subtitle = styled('p')` + font-size: ${p => p.theme.fontSizeSmall}; + color: ${p => p.theme.subText}; +`; + +const ButtonContent = styled('div')<{color: string}>` + display: flex; + align-items: center; + gap: ${space(1)}; + color: ${p => p.color}; +`; + +const Feature = styled('div')` + display: flex; + gap: ${space(1)}; + align-items: center; + align-content: center; + svg { + flex-shrink: 0; + } +`; diff --git a/static/gsApp/views/amCheckout/types.tsx b/static/gsApp/views/amCheckout/types.tsx index 843376eb0ebb1d..14ec0664a36b3f 100644 --- a/static/gsApp/views/amCheckout/types.tsx +++ b/static/gsApp/views/amCheckout/types.tsx @@ -11,20 +11,28 @@ import type { Subscription, } from 'getsentry/types'; +export enum SelectableProduct { + SEER = 'seer', // should match ReservedBudgetCategoryType.SEER +} + type BaseCheckoutData = { plan: string; + selectedProducts: Record; applyNow?: boolean; onDemandBudget?: OnDemandBudgets; onDemandMaxSpend?: number; - seerBudget?: number; - seerEnabled?: boolean; +}; + +export type SelectedProductData = { + enabled: boolean; + budget?: number; // if not provided, the default budget will be used }; export type CheckoutFormData = BaseCheckoutData & { reserved: Partial>; }; -export type CheckoutAPIData = BaseCheckoutData & { +export type CheckoutAPIData = Omit & { paymentIntent?: string; previewToken?: string; referrer?: string; @@ -37,7 +45,7 @@ export type CheckoutAPIData = BaseCheckoutData & { reservedSpans?: number; reservedTransactions?: number; reservedUptime?: number; - seer?: boolean; + seer?: boolean; // TODO: in future, we should just use selectedProducts }; export type StepProps = { diff --git a/static/gsApp/views/amCheckout/utils.tsx b/static/gsApp/views/amCheckout/utils.tsx index df57f8d9a641b6..49df4fc4100dd7 100644 --- a/static/gsApp/views/amCheckout/utils.tsx +++ b/static/gsApp/views/amCheckout/utils.tsx @@ -14,7 +14,12 @@ import type {Organization} from 'sentry/types/organization'; import {browserHistory} from 'sentry/utils/browserHistory'; import normalizeUrl from 'sentry/utils/url/normalizeUrl'; -import {DEFAULT_TIER, MONTHLY, SUPPORTED_TIERS} from 'getsentry/constants'; +import { + DEFAULT_TIER, + MONTHLY, + RESERVED_BUDGET_QUOTA, + SUPPORTED_TIERS, +} from 'getsentry/constants'; import SubscriptionStore from 'getsentry/stores/subscriptionStore'; import type { EventBucket, @@ -22,13 +27,19 @@ import type { Plan, PlanTier, PreviewData, + ReservedBudgetCategoryType, Subscription, } from 'getsentry/types'; import {InvoiceItemType} from 'getsentry/types'; import {getSlot} from 'getsentry/utils/billing'; import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; import trackMarketingEvent from 'getsentry/utils/trackMarketingEvent'; -import type {CheckoutAPIData, CheckoutFormData} from 'getsentry/views/amCheckout/types'; +import type { + CheckoutAPIData, + CheckoutFormData, + SelectableProduct, + SelectedProductData, +} from 'getsentry/views/amCheckout/types'; import { normalizeOnDemandBudget, parseOnDemandBudgetsFromSubscription, @@ -175,12 +186,11 @@ export function getBucket({ type ReservedTotalProps = { plan: Plan; reserved: Partial>; + selectedProducts: Record; amount?: number; creditCategory?: InvoiceItemType; discountType?: string; maxDiscount?: number; - seerBudget?: number; - seerEnabled?: boolean; }; /** @@ -193,8 +203,7 @@ export function getReservedPriceCents({ discountType, maxDiscount, creditCategory, - seerEnabled, - seerBudget, + selectedProducts, }: ReservedTotalProps): number { let reservedCents = plan.basePrice; @@ -215,16 +224,29 @@ export function getReservedPriceCents({ }).price) ); + Object.entries(selectedProducts).forEach(([apiName, selectedProductData]) => { + if (selectedProductData.enabled) { + // TODO: replace this to use the pricing schedule once that's serialized on availableReservedBudgetTypes + // This way, we can handle both annual and monthly prices + const budgetTypeInfo = + plan.availableReservedBudgetTypes[apiName as ReservedBudgetCategoryType]; + if (budgetTypeInfo) { + reservedCents += budgetTypeInfo.dataCategories.reduce((acc, dataCategory) => { + const bucket = getBucket({ + events: RESERVED_BUDGET_QUOTA, + buckets: plan.planCategories[dataCategory], + }); + return acc + bucket.price; + }, 0); + } + } + }); + if (amount && maxDiscount) { const discount = Math.min(maxDiscount, (reservedCents * amount) / 10000); reservedCents -= discount; } - // Add Seer budget to the total if it's enabled - if (seerEnabled && seerBudget) { - reservedCents += seerBudget; - } - return reservedCents; } @@ -239,8 +261,7 @@ export function getReservedTotal({ discountType, maxDiscount, creditCategory, - seerEnabled, - seerBudget, + selectedProducts, }: ReservedTotalProps): string { return formatPrice({ cents: getReservedPriceCents({ @@ -250,8 +271,7 @@ export function getReservedTotal({ discountType, maxDiscount, creditCategory, - seerEnabled, - seerBudget, + selectedProducts, }), }); } @@ -479,9 +499,7 @@ export function getCheckoutAPIData({ referrer: referrer || 'billing', ...(previewToken && {previewToken}), ...(paymentIntent && {paymentIntent}), - ...(formData.seerEnabled && { - seer: formData.seerEnabled, - }), + seer: formData.selectedProducts.seer?.enabled, // TODO: in future, we should just be able to pass selectedProducts }; if (formData.applyNow) { From b03002c6874026f0a01d0c3ccfd4e99913007f31 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Fri, 16 May 2025 15:12:25 -0400 Subject: [PATCH 3/9] typing fixes --- .../amCheckout/checkoutOverview.spec.tsx | 52 ++++++------------- .../views/amCheckout/checkoutOverview.tsx | 41 --------------- .../amCheckout/checkoutOverviewV2.spec.tsx | 44 +++++++++++++++- .../views/amCheckout/checkoutOverviewV2.tsx | 4 +- .../views/amCheckout/steps/productSelect.tsx | 6 +-- .../steps/reviewAndConfirm.spec.tsx | 7 +++ static/gsApp/views/amCheckout/types.tsx | 2 +- static/gsApp/views/amCheckout/utils.spec.tsx | 19 +++++-- static/gsApp/views/amCheckout/utils.tsx | 6 +-- tests/js/getsentry-test/fixtures/am1Plans.ts | 1 + tests/js/getsentry-test/fixtures/am2Plans.ts | 1 + tests/js/getsentry-test/fixtures/am3Plans.ts | 2 + .../getsentry-test/fixtures/reservedBudget.ts | 4 ++ .../getsentry-test/fixtures/subscription.ts | 16 ++---- 14 files changed, 100 insertions(+), 105 deletions(-) diff --git a/static/gsApp/views/amCheckout/checkoutOverview.spec.tsx b/static/gsApp/views/amCheckout/checkoutOverview.spec.tsx index e7dd0d4fc47237..7aa0ae69dffcce 100644 --- a/static/gsApp/views/amCheckout/checkoutOverview.spec.tsx +++ b/static/gsApp/views/amCheckout/checkoutOverview.spec.tsx @@ -10,7 +10,7 @@ import SubscriptionStore from 'getsentry/stores/subscriptionStore'; import {OnDemandBudgetMode, PlanTier} from 'getsentry/types'; import AMCheckout from 'getsentry/views/amCheckout/'; import CheckoutOverview from 'getsentry/views/amCheckout/checkoutOverview'; -import type {CheckoutFormData} from 'getsentry/views/amCheckout/types'; +import {type CheckoutFormData, SelectableProduct} from 'getsentry/views/amCheckout/types'; describe('CheckoutOverview', function () { const api = new MockApiClient(); @@ -175,12 +175,16 @@ describe('CheckoutOverview', function () { expect(screen.queryByTestId('on-demand-additional-cost')).not.toBeInTheDocument(); }); - it('displays Seer Agent AI when enabled and feature flag is present', () => { + it('displays Seer Agent AI when enabled', () => { const orgWithSeerFeature = {...organization, features: ['seer-billing']}; const formData: CheckoutFormData = { plan: 'am2_team', reserved: {errors: 100000, transactions: 500000, attachments: 25}, - seerEnabled: true, + selectedProducts: { + [SelectableProduct.SEER]: { + enabled: true, + }, + }, }; render( @@ -194,19 +198,20 @@ describe('CheckoutOverview', function () { /> ); - expect(screen.getByTestId('seer')).toBeInTheDocument(); - expect(screen.getByText('Seer: Sentry AI Enhancements')).toBeInTheDocument(); - expect( - screen.getByText('Surface insights and propose solutions to fix bugs faster.') - ).toBeInTheDocument(); + expect(screen.getByTestId('seer-reserved')).toBeInTheDocument(); + expect(screen.getByText('Seer')).toBeInTheDocument(); }); - it('does not display Seer Agent AI when seerEnabled is false', () => { + it('does not display Seer Agent AI when not bought', () => { const orgWithSeerFeature = {...organization, features: ['seer-billing']}; const formData: CheckoutFormData = { plan: 'am2_team', reserved: {errors: 100000, transactions: 500000, attachments: 25}, - seerEnabled: false, + selectedProducts: { + [SelectableProduct.SEER]: { + enabled: false, + }, + }, }; render( @@ -221,31 +226,6 @@ describe('CheckoutOverview', function () { ); expect(screen.queryByTestId('seer')).not.toBeInTheDocument(); - expect(screen.queryByText('Seer: Sentry AI Enhancements')).not.toBeInTheDocument(); - }); - - it('does not display Seer Agent AI when enabled but feature flag is missing', () => { - const orgWithoutSeerFeature = {...organization, features: []}; - const formData: CheckoutFormData = { - plan: 'am2_team', - reserved: {errors: 100000, transactions: 500000, attachments: 25}, - seerEnabled: true, - }; - - jest.spyOn(CheckoutOverview.prototype, 'renderSeer').mockReturnValue(null); - - render( - - ); - - expect(screen.queryByTestId('seer')).not.toBeInTheDocument(); - expect(screen.queryByText('Seer: Sentry AI Enhancements')).not.toBeInTheDocument(); + expect(screen.queryByText('Seer')).not.toBeInTheDocument(); }); }); diff --git a/static/gsApp/views/amCheckout/checkoutOverview.tsx b/static/gsApp/views/amCheckout/checkoutOverview.tsx index 8ff56f81f85ae8..5c34b61313d1b5 100644 --- a/static/gsApp/views/amCheckout/checkoutOverview.tsx +++ b/static/gsApp/views/amCheckout/checkoutOverview.tsx @@ -174,47 +174,6 @@ class CheckoutOverview extends Component { ); } - // renderAdditionalFeature({ - // featureKey, - // title, - // description, - // priceCents, - // enabledField, - // }: { - // description: string; - // enabledField: string; - // featureKey: string; - // priceCents: number; - // title: string; - // }) { - // const {formData} = this.props; - // const isEnabled = formData[enabledField as keyof CheckoutFormData]; - - // if (!isEnabled) { - // return null; - // } - - // return ( - // - //
- // {title} - // {description} - //
- // {`${utils.displayPrice({cents: priceCents})}/mo`} - //
- // ); - // } - - // renderSeer() { - // return this.renderAdditionalFeature({ - // featureKey: 'seer', - // title: t('Seer: Sentry AI Enhancements'), - // description: t('Surface insights and propose solutions to fix bugs faster.'), - // priceCents: SEER_MONTHLY_PRICE_CENTS, - // enabledField: 'seerEnabled', - // }); - // } - renderDetailItems = () => { const {activePlan, discountInfo} = this.props; diff --git a/static/gsApp/views/amCheckout/checkoutOverviewV2.spec.tsx b/static/gsApp/views/amCheckout/checkoutOverviewV2.spec.tsx index 7f34576e9ae7e9..a0bc76faeed8c6 100644 --- a/static/gsApp/views/amCheckout/checkoutOverviewV2.spec.tsx +++ b/static/gsApp/views/amCheckout/checkoutOverviewV2.spec.tsx @@ -8,7 +8,7 @@ import SubscriptionStore from 'getsentry/stores/subscriptionStore'; import {PlanTier} from 'getsentry/types'; import AMCheckout from 'getsentry/views/amCheckout/'; import CheckoutOverviewV2 from 'getsentry/views/amCheckout/checkoutOverviewV2'; -import type {CheckoutFormData} from 'getsentry/views/amCheckout/types'; +import {type CheckoutFormData, SelectableProduct} from 'getsentry/views/amCheckout/types'; describe('CheckoutOverviewV2', function () { const api = new MockApiClient(); @@ -81,6 +81,11 @@ describe('CheckoutOverviewV2', function () { uptime: 1, }, onDemandMaxSpend: 5000, + selectedProducts: { + [SelectableProduct.SEER]: { + enabled: true, + }, + }, }; render( @@ -94,8 +99,8 @@ describe('CheckoutOverviewV2', function () { /> ); - expect(screen.getByText('Sentry Team Plan')).toBeInTheDocument(); expect(screen.getByText('All Sentry Products')).toBeInTheDocument(); + expect(screen.getByTestId('seer-reserved')).toBeInTheDocument(); // .toHaveTextContent('Seer $216/yr'); TODO(seer): uncomment this once fixtures are updated with pricing schedule expect(screen.getByText('Total Annual Charges')).toBeInTheDocument(); expect(screen.getByText('$312/yr')).toBeInTheDocument(); expect(screen.getByTestId('additional-monthly-charge')).toHaveTextContent( @@ -161,4 +166,39 @@ describe('CheckoutOverviewV2', function () { expect(screen.queryByTestId('additional-monthly-charge')).not.toBeInTheDocument(); expect(screen.getAllByText('Product not available')[0]).toBeInTheDocument(); }); + + it('does not show seer when not enabled', function () { + const formData: CheckoutFormData = { + plan: 'am3_team_auf', + reserved: { + errors: 100000, + attachments: 25, + replays: 50, + spans: 10_000_000, + monitorSeats: 1, + profileDuration: 0, + profileDurationUI: 0, + uptime: 1, + }, + onDemandMaxSpend: 5000, + selectedProducts: { + [SelectableProduct.SEER]: { + enabled: false, + }, + }, + }; + + render( + + ); + + expect(screen.queryByTestId('seer-reserved')).not.toBeInTheDocument(); + }); }); diff --git a/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx b/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx index 073c3bb250b476..6d5b226d093c8a 100644 --- a/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx +++ b/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx @@ -170,7 +170,7 @@ function CheckoutOverviewV2({activePlan, formData, onUpdate: _onUpdate}: Props) const hasAtLeastOneSelectedProduct = Object.values( activePlan.availableReservedBudgetTypes ).some(budgetTypeInfo => { - return formData.selectedProducts[ + return formData.selectedProducts?.[ budgetTypeInfo.apiName as string as SelectableProduct ]?.enabled; }); @@ -187,7 +187,7 @@ function CheckoutOverviewV2({activePlan, formData, onUpdate: _onUpdate}: Props) {Object.values(activePlan.availableReservedBudgetTypes).map( budgetTypeInfo => { const formDataForProduct = - formData.selectedProducts[ + formData.selectedProducts?.[ budgetTypeInfo.apiName as string as SelectableProduct ]; if (!formDataForProduct) { diff --git a/static/gsApp/views/amCheckout/steps/productSelect.tsx b/static/gsApp/views/amCheckout/steps/productSelect.tsx index 9d051ca2edee54..05f67ee7eee187 100644 --- a/static/gsApp/views/amCheckout/steps/productSelect.tsx +++ b/static/gsApp/views/amCheckout/steps/productSelect.tsx @@ -84,7 +84,7 @@ function ProductSelect({ ...formData.selectedProducts, [productInfo.apiName]: { enabled: - !formData.selectedProducts[ + !formData.selectedProducts?.[ productInfo.apiName as string as SelectableProduct ]?.enabled, }, @@ -94,14 +94,14 @@ function ProductSelect({ > - {formData.selectedProducts[ + {formData.selectedProducts?.[ productInfo.apiName as string as SelectableProduct ]?.enabled ? ( diff --git a/static/gsApp/views/amCheckout/steps/reviewAndConfirm.spec.tsx b/static/gsApp/views/amCheckout/steps/reviewAndConfirm.spec.tsx index 21290d33744636..a08d4ca5b3eccd 100644 --- a/static/gsApp/views/amCheckout/steps/reviewAndConfirm.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/reviewAndConfirm.spec.tsx @@ -17,6 +17,7 @@ import SubscriptionStore from 'getsentry/stores/subscriptionStore'; import {PlanTier} from 'getsentry/types'; import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; import AMCheckout from 'getsentry/views/amCheckout/'; +import {SelectableProduct} from 'getsentry/views/amCheckout/types'; import {getCheckoutAPIData} from 'getsentry/views/amCheckout/utils'; import ReviewAndConfirm from './reviewAndConfirm'; @@ -264,6 +265,11 @@ describe('AmCheckout > ReviewAndConfirm', function () { const updatedData = { ...formData, reserved: {...formData.reserved, errors: reservedErrors}, + selectedProducts: { + [SelectableProduct.SEER]: { + enabled: true, + }, + }, }; render(, { deprecatedRouterMocks: true, @@ -287,6 +293,7 @@ describe('AmCheckout > ReviewAndConfirm', function () { ) ); + // TODO: Add seer analytics expect(trackGetsentryAnalytics).toHaveBeenCalledWith('checkout.upgrade', { organization, subscription, diff --git a/static/gsApp/views/amCheckout/types.tsx b/static/gsApp/views/amCheckout/types.tsx index 14ec0664a36b3f..0589fa1e36c53e 100644 --- a/static/gsApp/views/amCheckout/types.tsx +++ b/static/gsApp/views/amCheckout/types.tsx @@ -17,10 +17,10 @@ export enum SelectableProduct { type BaseCheckoutData = { plan: string; - selectedProducts: Record; applyNow?: boolean; onDemandBudget?: OnDemandBudgets; onDemandMaxSpend?: number; + selectedProducts?: Record; }; export type SelectedProductData = { diff --git a/static/gsApp/views/amCheckout/utils.spec.tsx b/static/gsApp/views/amCheckout/utils.spec.tsx index 563f8753313e83..af8a387532b8e8 100644 --- a/static/gsApp/views/amCheckout/utils.spec.tsx +++ b/static/gsApp/views/amCheckout/utils.spec.tsx @@ -3,6 +3,7 @@ import {PlanDetailsLookupFixture} from 'getsentry-test/fixtures/planDetailsLooku import {DataCategory} from 'sentry/types/core'; import {InvoiceItemType, PlanTier} from 'getsentry/types'; +import {SelectableProduct} from 'getsentry/views/amCheckout/types'; import * as utils from 'getsentry/views/amCheckout/utils'; import {getCheckoutAPIData} from 'getsentry/views/amCheckout/utils'; @@ -11,6 +12,11 @@ describe('utils', function () { const teamPlanAnnual = PlanDetailsLookupFixture('am1_team_auf')!; const bizPlan = PlanDetailsLookupFixture('am1_business')!; const bizPlanAnnual = PlanDetailsLookupFixture('am1_business_auf')!; + const DEFAULT_SELECTED_PRODUCTS = { + [SelectableProduct.SEER]: { + enabled: false, + }, + }; describe('getReservedTotal', function () { it('can get base price for team plan', function () { @@ -21,6 +27,7 @@ describe('utils', function () { transactions: 100_000, attachments: 1, }, + selectedProducts: DEFAULT_SELECTED_PRODUCTS, }); expect(priceDollars).toBe('29'); }); @@ -33,6 +40,7 @@ describe('utils', function () { transactions: 100_000, attachments: 1, }, + selectedProducts: DEFAULT_SELECTED_PRODUCTS, }); expect(priceDollars).toBe('312'); }); @@ -45,6 +53,7 @@ describe('utils', function () { transactions: 100_000, attachments: 1, }, + selectedProducts: DEFAULT_SELECTED_PRODUCTS, }); expect(priceDollars).toBe('89'); }); @@ -57,6 +66,7 @@ describe('utils', function () { transactions: 100_000, attachments: 1, }, + selectedProducts: DEFAULT_SELECTED_PRODUCTS, }); expect(priceDollars).toBe('960'); }); @@ -69,6 +79,7 @@ describe('utils', function () { transactions: 100_000, attachments: 1, }, + selectedProducts: DEFAULT_SELECTED_PRODUCTS, }); expect(priceDollars).toBe('1,992'); }); @@ -81,8 +92,7 @@ describe('utils', function () { transactions: 100_000, attachments: 1, }, - seerEnabled: true, - seerBudget: 2000, + selectedProducts: DEFAULT_SELECTED_PRODUCTS, }); expect(priceDollars).toBe('49'); }); @@ -95,8 +105,7 @@ describe('utils', function () { transactions: 100_000, attachments: 1, }, - seerEnabled: false, - seerBudget: 2000, + selectedProducts: DEFAULT_SELECTED_PRODUCTS, }); expect(priceDollars).toBe('29'); }); @@ -369,6 +378,7 @@ describe('utils', function () { attachments: 70, profileDuration: 80, }, + selectedProducts: DEFAULT_SELECTED_PRODUCTS, }; expect(getCheckoutAPIData({formData})).toEqual({ @@ -383,6 +393,7 @@ describe('utils', function () { reservedUptime: 60, reservedAttachments: 70, reservedProfileDuration: 80, + seer: false, }); }); }); diff --git a/static/gsApp/views/amCheckout/utils.tsx b/static/gsApp/views/amCheckout/utils.tsx index 49df4fc4100dd7..af04ca9879f78a 100644 --- a/static/gsApp/views/amCheckout/utils.tsx +++ b/static/gsApp/views/amCheckout/utils.tsx @@ -186,11 +186,11 @@ export function getBucket({ type ReservedTotalProps = { plan: Plan; reserved: Partial>; - selectedProducts: Record; amount?: number; creditCategory?: InvoiceItemType; discountType?: string; maxDiscount?: number; + selectedProducts?: Record; }; /** @@ -224,7 +224,7 @@ export function getReservedPriceCents({ }).price) ); - Object.entries(selectedProducts).forEach(([apiName, selectedProductData]) => { + Object.entries(selectedProducts ?? {}).forEach(([apiName, selectedProductData]) => { if (selectedProductData.enabled) { // TODO: replace this to use the pricing schedule once that's serialized on availableReservedBudgetTypes // This way, we can handle both annual and monthly prices @@ -499,7 +499,7 @@ export function getCheckoutAPIData({ referrer: referrer || 'billing', ...(previewToken && {previewToken}), ...(paymentIntent && {paymentIntent}), - seer: formData.selectedProducts.seer?.enabled, // TODO: in future, we should just be able to pass selectedProducts + seer: formData.selectedProducts?.seer?.enabled, // TODO: in future, we should just be able to pass selectedProducts }; if (formData.applyNow) { diff --git a/tests/js/getsentry-test/fixtures/am1Plans.ts b/tests/js/getsentry-test/fixtures/am1Plans.ts index 5b5ebff31ce6e5..737948f1c87b26 100644 --- a/tests/js/getsentry-test/fixtures/am1Plans.ts +++ b/tests/js/getsentry-test/fixtures/am1Plans.ts @@ -42,6 +42,7 @@ const AM1_AVAILABLE_RESERVED_BUDGET_TYPES = { productName: 'seer', canProductTrial: true, apiName: ReservedBudgetCategoryType.SEER, + billingFlag: 'seer-billing', }, }; diff --git a/tests/js/getsentry-test/fixtures/am2Plans.ts b/tests/js/getsentry-test/fixtures/am2Plans.ts index 0b9a8b6d967143..8cfe9e7e6a3b7a 100644 --- a/tests/js/getsentry-test/fixtures/am2Plans.ts +++ b/tests/js/getsentry-test/fixtures/am2Plans.ts @@ -52,6 +52,7 @@ const AM2_AVAILABLE_RESERVED_BUDGET_TYPES = { productName: 'seer', canProductTrial: true, apiName: ReservedBudgetCategoryType.SEER, + billingFlag: 'seer-billing', }, }; diff --git a/tests/js/getsentry-test/fixtures/am3Plans.ts b/tests/js/getsentry-test/fixtures/am3Plans.ts index 57d92ef6caad99..8d1ab299895b88 100644 --- a/tests/js/getsentry-test/fixtures/am3Plans.ts +++ b/tests/js/getsentry-test/fixtures/am3Plans.ts @@ -47,6 +47,7 @@ const AM3_AVAILABLE_RESERVED_BUDGET_TYPES = { productName: 'seer', canProductTrial: true, apiName: ReservedBudgetCategoryType.SEER, + billingFlag: 'seer-billing', }, }; @@ -62,6 +63,7 @@ const AM3_DS_AVAILABLE_RESERVED_BUDGET_TYPES = { productName: 'dynamic sampling', canProductTrial: false, apiName: ReservedBudgetCategoryType.DYNAMIC_SAMPLING, + billingFlag: null, }, }; diff --git a/tests/js/getsentry-test/fixtures/reservedBudget.ts b/tests/js/getsentry-test/fixtures/reservedBudget.ts index ea9a60c6c01cf6..dc4ea1021cc954 100644 --- a/tests/js/getsentry-test/fixtures/reservedBudget.ts +++ b/tests/js/getsentry-test/fixtures/reservedBudget.ts @@ -38,6 +38,7 @@ export function ReservedBudgetFixture(props: BudgetProps) { dataCategories: [], productName: '', canProductTrial: false, + billingFlag: null, }; return { @@ -91,6 +92,7 @@ export function SeerReservedBudgetFixture(props: BudgetProps) { dataCategories: [DataCategory.SEER_AUTOFIX, DataCategory.SEER_SCANNER], productName: 'seer', canProductTrial: true, + billingFlag: 'seer-billing', ...props, }; @@ -119,6 +121,8 @@ export function DynamicSamplingReservedBudgetFixture(props: BudgetProps) { dataCategories: [DataCategory.SPANS, DataCategory.SPANS_INDEXED], productName: 'dynamic sampling', canProductTrial: false, + billingFlag: null, + apiName: ReservedBudgetCategoryType.DYNAMIC_SAMPLING, ...props, }; diff --git a/tests/js/getsentry-test/fixtures/subscription.ts b/tests/js/getsentry-test/fixtures/subscription.ts index 16a685a93583d8..6ee94be79a515e 100644 --- a/tests/js/getsentry-test/fixtures/subscription.ts +++ b/tests/js/getsentry-test/fixtures/subscription.ts @@ -1,7 +1,7 @@ import {MetricHistoryFixture} from 'getsentry-test/fixtures/metricHistory'; import {PlanDetailsLookupFixture} from 'getsentry-test/fixtures/planDetailsLookup'; import { - ReservedBudgetFixture, + DynamicSamplingReservedBudgetFixture, ReservedBudgetMetricHistoryFixture, } from 'getsentry-test/fixtures/reservedBudget'; @@ -10,7 +10,7 @@ import type {Organization} from 'sentry/types/organization'; import {RESERVED_BUDGET_QUOTA} from 'getsentry/constants'; import type {Plan, Subscription as TSubscription} from 'getsentry/types'; -import {BillingType, ReservedBudgetCategoryType} from 'getsentry/types'; +import {BillingType} from 'getsentry/types'; type Props = Partial & {organization: Organization}; @@ -404,17 +404,7 @@ export function Am3DsEnterpriseSubscriptionFixture(props: Props): TSubscription DataCategory.SPANS_INDEXED, ]; subscription.reservedBudgets = [ - ReservedBudgetFixture({ - id: '11', - apiName: ReservedBudgetCategoryType.DYNAMIC_SAMPLING, - budgetCategoryType: 'DYNAMIC_SAMPLING', - canProductTrial: false, - dataCategories: [DataCategory.SPANS, DataCategory.SPANS_INDEXED], - defaultBudget: null, - docLink: '', - isFixed: false, - name: 'spans budget', - productName: 'dynamic sampling', + DynamicSamplingReservedBudgetFixture({ reservedBudget: 100_000_00, totalReservedSpend: 60_000_00, freeBudget: 0, From 4f5e563309b2d5a3c8740e22798cea7580006805 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Fri, 16 May 2025 15:48:04 -0400 Subject: [PATCH 4/9] styling --- .../views/amCheckout/checkoutOverviewV2.tsx | 20 ++--- .../views/amCheckout/steps/productSelect.tsx | 76 +++++++++++++------ static/gsApp/views/amCheckout/utils.tsx | 34 +++++++-- 3 files changed, 85 insertions(+), 45 deletions(-) diff --git a/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx b/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx index 6d5b226d093c8a..59f4f21a45ea23 100644 --- a/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx +++ b/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx @@ -12,11 +12,7 @@ import {DataCategory} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; -import { - PAYG_BUSINESS_DEFAULT, - PAYG_TEAM_DEFAULT, - RESERVED_BUDGET_QUOTA, -} from 'getsentry/constants'; +import {PAYG_BUSINESS_DEFAULT, PAYG_TEAM_DEFAULT} from 'getsentry/constants'; import type {BillingConfig, Plan, Promotion, Subscription} from 'getsentry/types'; import {formatReservedWithUnits, isBizPlanFamily} from 'getsentry/utils/billing'; import {getPlanCategoryName, getSingularCategoryName} from 'getsentry/utils/dataCategory'; @@ -212,16 +208,10 @@ function CheckoutOverviewV2({activePlan, formData, onUpdate: _onUpdate}: Props) {utils.displayPrice({ - cents: budgetTypeInfo.dataCategories.reduce( - (acc, dataCategory) => { - const bucket = utils.getBucket({ - events: RESERVED_BUDGET_QUOTA, - buckets: activePlan.planCategories[dataCategory], - }); - return acc + bucket.price; - }, - 0 - ), + cents: utils.getReservedPriceForReservedBudgetCategory({ + plan: activePlan, + reservedBudgetCategory: budgetTypeInfo.apiName, + }), })} /{shortInterval} diff --git a/static/gsApp/views/amCheckout/steps/productSelect.tsx b/static/gsApp/views/amCheckout/steps/productSelect.tsx index 05f67ee7eee187..dc559e7b6737a4 100644 --- a/static/gsApp/views/amCheckout/steps/productSelect.tsx +++ b/static/gsApp/views/amCheckout/steps/productSelect.tsx @@ -2,6 +2,8 @@ import {Fragment} from 'react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; +import bannerStars from 'sentry-images/spot/ai-suggestion-banner-stars.svg'; + import {SeerIcon} from 'sentry/components/ai/SeerIcon'; import {Button} from 'sentry/components/core/button'; import PanelItem from 'sentry/components/panels/panelItem'; @@ -60,7 +62,14 @@ function ProductSelect({ return ( - + {checkoutInfo.icon} @@ -109,8 +118,14 @@ function ProductSelect({ ) : ( - {formatCurrency(productInfo.defaultBudget!)}/ - {billingInterval} + {' '} + {formatCurrency( + utils.getReservedPriceForReservedBudgetCategory({ + plan: activePlan, + reservedBudgetCategory: productInfo.apiName, + }) + )} + /{billingInterval} )} @@ -126,30 +141,16 @@ function ProductSelect({ size="xs" /> + + + - {/* */} ); })} ); - - // const renderSeer = () => { - // return renderAdditionalFeature({ - // featureKey: 'seer', - // featureEnabled: seerIsEnabled, - // featureAvailable: hasSeerFeature, - // title: 'Add Seer, our AI Agent', - // description: 'Insights and solutions to fix bugs faster', - // icon: , - // priceCents: SEER_MONTHLY_PRICE_CENTS, - // featureItems: ['Root Cause Analysis', 'Autofix PRs', 'AI Issue Priority'], - // tooltipTitle: t('Additional Seer information.'), - // isNew: true, - // setFeatureEnabled: setSeerIsEnabled, - // }); - // }; } export default ProductSelect; @@ -165,15 +166,19 @@ const ProductOption = styled(PanelItem)<{isSelected?: boolean}>` display: inherit; `; -const ProductOptionContent = styled('div')<{gradientColor: string}>` +const ProductOptionContent = styled('div')<{gradientColor: string; enabled?: boolean}>` padding: ${space(2)}; - background-color: ${p => p.theme.backgroundSecondary}; + background-color: ${p => (p.enabled ? p.gradientColor : p.theme.backgroundSecondary)}; display: flex; gap: ${space(4)}; justify-content: space-between; - border: 1px solid ${p => p.theme.innerBorder}; + border: 1px solid ${p => (p.enabled ? p.gradientColor : p.theme.innerBorder)}; border-radius: ${p => p.theme.borderRadius}; + button { + border-color: ${p => (p.enabled ? p.gradientColor : p.theme.innerBorder)}; + } + @keyframes gradient { 0% { background-position: 0% 50%; @@ -243,3 +248,28 @@ const Feature = styled('div')` flex-shrink: 0; } `; + +const IllustrationContainer = styled('div')` + display: none; + + @media (min-width: ${p => p.theme.breakpoints.large}) { + display: block; + position: absolute; + bottom: 83px; + right: 12px; + top: 0; + width: 600px; + overflow: hidden; + border-radius: 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0; + pointer-events: none; + } +`; + +const Stars = styled('img')` + pointer-events: none; + position: absolute; + right: -400px; + bottom: -70px; + overflow: hidden; + height: 250px; +`; diff --git a/static/gsApp/views/amCheckout/utils.tsx b/static/gsApp/views/amCheckout/utils.tsx index af04ca9879f78a..258a84493610c1 100644 --- a/static/gsApp/views/amCheckout/utils.tsx +++ b/static/gsApp/views/amCheckout/utils.tsx @@ -193,6 +193,29 @@ type ReservedTotalProps = { selectedProducts?: Record; }; +/** + * Returns the price for a reserved budget category (ie. Seer) in cents. + */ +export function getReservedPriceForReservedBudgetCategory({ + plan, + reservedBudgetCategory, +}: { + plan: Plan; + reservedBudgetCategory: ReservedBudgetCategoryType; +}): number { + const budgetTypeInfo = plan.availableReservedBudgetTypes[reservedBudgetCategory]; + if (!budgetTypeInfo) { + return 0; + } + return budgetTypeInfo.dataCategories.reduce((acc, dataCategory) => { + const bucket = getBucket({ + events: RESERVED_BUDGET_QUOTA, + buckets: plan.planCategories[dataCategory], + }); + return acc + bucket.price; + }, 0); +} + /** * Returns the total plan price (including prices for reserved categories) in cents. */ @@ -231,13 +254,10 @@ export function getReservedPriceCents({ const budgetTypeInfo = plan.availableReservedBudgetTypes[apiName as ReservedBudgetCategoryType]; if (budgetTypeInfo) { - reservedCents += budgetTypeInfo.dataCategories.reduce((acc, dataCategory) => { - const bucket = getBucket({ - events: RESERVED_BUDGET_QUOTA, - buckets: plan.planCategories[dataCategory], - }); - return acc + bucket.price; - }, 0); + reservedCents += getReservedPriceForReservedBudgetCategory({ + plan, + reservedBudgetCategory: apiName as ReservedBudgetCategoryType, + }); } } }); From 17539e618e4a1d351443c188416528be91fbdc3d Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Fri, 16 May 2025 16:11:43 -0400 Subject: [PATCH 5/9] fix more tests --- .../views/amCheckout/checkoutOverview.tsx | 40 ++++++++++- .../amCheckout/steps/onDemandBudgets.spec.tsx | 1 + static/gsApp/views/amCheckout/utils.spec.tsx | 33 +++++++-- tests/js/getsentry-test/fixtures/am2Plans.ts | 67 +++++++++++++++++++ tests/js/getsentry-test/fixtures/am3Plans.ts | 67 +++++++++++++++++++ .../getsentry-test/fixtures/subscription.ts | 1 + 6 files changed, 203 insertions(+), 6 deletions(-) diff --git a/static/gsApp/views/amCheckout/checkoutOverview.tsx b/static/gsApp/views/amCheckout/checkoutOverview.tsx index 5c34b61313d1b5..92373b8b2fbc46 100644 --- a/static/gsApp/views/amCheckout/checkoutOverview.tsx +++ b/static/gsApp/views/amCheckout/checkoutOverview.tsx @@ -7,9 +7,16 @@ import PanelBody from 'sentry/components/panels/panelBody'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types/organization'; +import {toTitleCase} from 'sentry/utils/string/toTitleCase'; import {ANNUAL, MONTHLY} from 'getsentry/constants'; -import type {BillingConfig, Plan, Promotion, Subscription} from 'getsentry/types'; +import type { + BillingConfig, + Plan, + Promotion, + ReservedBudgetCategoryType, + Subscription, +} from 'getsentry/types'; import {OnDemandBudgetMode} from 'getsentry/types'; import {formatReservedWithUnits} from 'getsentry/utils/billing'; import {getPlanCategoryName} from 'getsentry/utils/dataCategory'; @@ -64,6 +71,35 @@ class CheckoutOverview extends Component { } }; + renderProducts = () => { + const {formData, activePlan} = this.props; + + return Object.entries(formData.selectedProducts ?? {}).map(([apiName, product]) => { + const productInfo = + activePlan.availableReservedBudgetTypes[apiName as ReservedBudgetCategoryType]; + if (!productInfo || !product.enabled) { + return null; + } + const price = utils.displayPrice({ + cents: utils.getReservedPriceForReservedBudgetCategory({ + plan: activePlan, + reservedBudgetCategory: productInfo.apiName, + }), + }); + return ( + + {toTitleCase(productInfo.productName)} + + {price}/{this.shortInterval} + + + ); + }); + }; + renderDataOptions = () => { const {formData, activePlan} = this.props; @@ -223,8 +259,8 @@ class CheckoutOverview extends Component { )} + {this.renderProducts()} {this.renderDataOptions()} - {/* {this.renderSeer()} */} {this.renderOnDemand()} ); diff --git a/static/gsApp/views/amCheckout/steps/onDemandBudgets.spec.tsx b/static/gsApp/views/amCheckout/steps/onDemandBudgets.spec.tsx index 577e42d2ddf19c..9dc2e461745152 100644 --- a/static/gsApp/views/amCheckout/steps/onDemandBudgets.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/onDemandBudgets.spec.tsx @@ -243,6 +243,7 @@ describe('OnDemandBudgets AM Checkout', function () { reservedSpans: undefined, reservedTransactions: 100000, reservedUptime: 1, + seer: false, }, }) ); diff --git a/static/gsApp/views/amCheckout/utils.spec.tsx b/static/gsApp/views/amCheckout/utils.spec.tsx index af8a387532b8e8..25109a08c61474 100644 --- a/static/gsApp/views/amCheckout/utils.spec.tsx +++ b/static/gsApp/views/amCheckout/utils.spec.tsx @@ -12,6 +12,8 @@ describe('utils', function () { const teamPlanAnnual = PlanDetailsLookupFixture('am1_team_auf')!; const bizPlan = PlanDetailsLookupFixture('am1_business')!; const bizPlanAnnual = PlanDetailsLookupFixture('am1_business_auf')!; + const am3TeamPlan = PlanDetailsLookupFixture('am3_team')!; + const am3TeamPlanAnnual = PlanDetailsLookupFixture('am3_team_auf')!; const DEFAULT_SELECTED_PRODUCTS = { [SelectableProduct.SEER]: { enabled: false, @@ -84,19 +86,42 @@ describe('utils', function () { expect(priceDollars).toBe('1,992'); }); - it('includes Seer budget in the total when enabled', function () { + it('includes Seer price in the total when enabled', function () { const priceDollars = utils.getReservedTotal({ - plan: teamPlan, + plan: am3TeamPlan, reserved: { errors: 50_000, - transactions: 100_000, + spans: 10_000_000, + replays: 50, attachments: 1, }, - selectedProducts: DEFAULT_SELECTED_PRODUCTS, + selectedProducts: { + [SelectableProduct.SEER]: { + enabled: true, + }, + }, }); expect(priceDollars).toBe('49'); }); + it('includes Seer annual price in the total when enabled', function () { + const priceDollars = utils.getReservedTotal({ + plan: am3TeamPlanAnnual, + reserved: { + errors: 50_000, + spans: 10_000_000, + replays: 50, + attachments: 1, + }, + selectedProducts: { + [SelectableProduct.SEER]: { + enabled: true, + }, + }, + }); + expect(priceDollars).toBe('528'); + }); + it('does not include Seer budget when not enabled', function () { const priceDollars = utils.getReservedTotal({ plan: teamPlan, diff --git a/tests/js/getsentry-test/fixtures/am2Plans.ts b/tests/js/getsentry-test/fixtures/am2Plans.ts index 8cfe9e7e6a3b7a..28afd5fbbfb431 100644 --- a/tests/js/getsentry-test/fixtures/am2Plans.ts +++ b/tests/js/getsentry-test/fixtures/am2Plans.ts @@ -115,6 +115,68 @@ const AM2_TRIAL_FEATURES = AM2_BUSINESS_FEATURES.filter( const BUDGET_TERM = 'on-demand'; +const SEER_TIERS = { + seerAutofix: [ + { + events: -2, + unitPrice: 0, + price: 20_00, + onDemandPrice: 125, + }, + { + events: 0, + unitPrice: 0, + price: 0, + onDemandPrice: 125, + }, + ], + seerScanner: [ + { + events: -2, + unitPrice: 0, + price: 0, + onDemandPrice: 1.25, + }, + { + events: 0, + unitPrice: 0, + price: 0, + onDemandPrice: 1.25, + }, + ], +}; + +const SEER_TIERS_ANNUAL = { + seerAutofix: [ + { + events: -2, + unitPrice: 0, + price: 216_00, + onDemandPrice: 125, + }, + { + events: 0, + unitPrice: 0, + price: 0, + onDemandPrice: 125, + }, + ], + seerScanner: [ + { + events: -2, + unitPrice: 0, + price: 0, + onDemandPrice: 1.25, + }, + { + events: 0, + unitPrice: 0, + price: 0, + onDemandPrice: 1.25, + }, + ], +}; + // TODO: Update with correct pricing and structure const AM2_PLANS: Record = { am2_business: { @@ -832,6 +894,7 @@ const AM2_PLANS: Record = { price: 0, }, ], + ...SEER_TIERS, }, budgetTerm: BUDGET_TERM, availableReservedBudgetTypes: AM2_AVAILABLE_RESERVED_BUDGET_TYPES, @@ -1638,6 +1701,8 @@ const AM2_PLANS: Record = { price: 0, }, ], + + ...SEER_TIERS, }, budgetTerm: BUDGET_TERM, availableReservedBudgetTypes: AM2_AVAILABLE_RESERVED_BUDGET_TYPES, @@ -2409,6 +2474,7 @@ const AM2_PLANS: Record = { price: 0, }, ], + ...SEER_TIERS_ANNUAL, }, }, am2_business_auf: { @@ -3090,6 +3156,7 @@ const AM2_PLANS: Record = { price: 0, }, ], + ...SEER_TIERS_ANNUAL, }, availableReservedBudgetTypes: AM2_AVAILABLE_RESERVED_BUDGET_TYPES, }, diff --git a/tests/js/getsentry-test/fixtures/am3Plans.ts b/tests/js/getsentry-test/fixtures/am3Plans.ts index 8d1ab299895b88..cded233e2f6f20 100644 --- a/tests/js/getsentry-test/fixtures/am3Plans.ts +++ b/tests/js/getsentry-test/fixtures/am3Plans.ts @@ -156,6 +156,68 @@ const AM3_DS_FEATURES = [ 'dynamic-sampling-custom', ]; +const SEER_TIERS = { + seerAutofix: [ + { + events: -2, + unitPrice: 0, + price: 20_00, + onDemandPrice: 125, + }, + { + events: 0, + unitPrice: 0, + price: 0, + onDemandPrice: 125, + }, + ], + seerScanner: [ + { + events: -2, + unitPrice: 0, + price: 0, + onDemandPrice: 1.25, + }, + { + events: 0, + unitPrice: 0, + price: 0, + onDemandPrice: 1.25, + }, + ], +}; + +const SEER_TIERS_ANNUAL = { + seerAutofix: [ + { + events: -2, + unitPrice: 0, + price: 216_00, + onDemandPrice: 125, + }, + { + events: 0, + unitPrice: 0, + price: 0, + onDemandPrice: 125, + }, + ], + seerScanner: [ + { + events: -2, + unitPrice: 0, + price: 0, + onDemandPrice: 1.25, + }, + { + events: 0, + unitPrice: 0, + price: 0, + onDemandPrice: 1.25, + }, + ], +}; + const BUDGET_TERM = 'pay-as-you-go'; const AM3_PLANS: Record = { @@ -868,6 +930,7 @@ const AM3_PLANS: Record = { onDemandPrice: 0.0004, }, ], + ...SEER_TIERS, }, categoryDisplayNames: AM3_CATEGORY_DISPLAY_NAMES, availableReservedBudgetTypes: AM3_AVAILABLE_RESERVED_BUDGET_TYPES, @@ -1365,6 +1428,7 @@ const AM3_PLANS: Record = { onDemandPrice: 31.25, }, ], + ...SEER_TIERS_ANNUAL, }, categoryDisplayNames: AM3_CATEGORY_DISPLAY_NAMES, availableReservedBudgetTypes: AM3_AVAILABLE_RESERVED_BUDGET_TYPES, @@ -1460,6 +1524,7 @@ const AM3_PLANS: Record = { onDemandPrice: 0.0, }, ], + ...SEER_TIERS, }, categoryDisplayNames: AM3_CATEGORY_DISPLAY_NAMES, availableReservedBudgetTypes: AM3_AVAILABLE_RESERVED_BUDGET_TYPES, @@ -2551,6 +2616,7 @@ const AM3_PLANS: Record = { onDemandPrice: 31.25, }, ], + ...SEER_TIERS, }, categoryDisplayNames: AM3_CATEGORY_DISPLAY_NAMES, availableReservedBudgetTypes: AM3_AVAILABLE_RESERVED_BUDGET_TYPES, @@ -3048,6 +3114,7 @@ const AM3_PLANS: Record = { onDemandPrice: 31.25, }, ], + ...SEER_TIERS_ANNUAL, }, categoryDisplayNames: AM3_CATEGORY_DISPLAY_NAMES, availableReservedBudgetTypes: AM3_AVAILABLE_RESERVED_BUDGET_TYPES, diff --git a/tests/js/getsentry-test/fixtures/subscription.ts b/tests/js/getsentry-test/fixtures/subscription.ts index 6ee94be79a515e..70f53fc2997f7e 100644 --- a/tests/js/getsentry-test/fixtures/subscription.ts +++ b/tests/js/getsentry-test/fixtures/subscription.ts @@ -405,6 +405,7 @@ export function Am3DsEnterpriseSubscriptionFixture(props: Props): TSubscription ]; subscription.reservedBudgets = [ DynamicSamplingReservedBudgetFixture({ + id: '11', reservedBudget: 100_000_00, totalReservedSpend: 60_000_00, freeBudget: 0, From 75ecdbe92225c0821cc2d29206348f366524991b Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Fri, 16 May 2025 16:16:30 -0400 Subject: [PATCH 6/9] cleanup --- .../amCheckout/checkoutOverviewV2.spec.tsx | 32 +++++++- .../views/amCheckout/checkoutOverviewV2.tsx | 53 ------------ .../views/amCheckout/steps/planSelect.tsx | 82 ------------------- static/gsApp/views/amCheckout/utils.tsx | 2 - 4 files changed, 31 insertions(+), 138 deletions(-) diff --git a/static/gsApp/views/amCheckout/checkoutOverviewV2.spec.tsx b/static/gsApp/views/amCheckout/checkoutOverviewV2.spec.tsx index a0bc76faeed8c6..57287438e7167f 100644 --- a/static/gsApp/views/amCheckout/checkoutOverviewV2.spec.tsx +++ b/static/gsApp/views/amCheckout/checkoutOverviewV2.spec.tsx @@ -100,7 +100,7 @@ describe('CheckoutOverviewV2', function () { ); expect(screen.getByText('All Sentry Products')).toBeInTheDocument(); - expect(screen.getByTestId('seer-reserved')).toBeInTheDocument(); // .toHaveTextContent('Seer $216/yr'); TODO(seer): uncomment this once fixtures are updated with pricing schedule + expect(screen.getByTestId('seer-reserved')).toHaveTextContent('Seer$216/yr'); expect(screen.getByText('Total Annual Charges')).toBeInTheDocument(); expect(screen.getByText('$312/yr')).toBeInTheDocument(); expect(screen.getByTestId('additional-monthly-charge')).toHaveTextContent( @@ -201,4 +201,34 @@ describe('CheckoutOverviewV2', function () { expect(screen.queryByTestId('seer-reserved')).not.toBeInTheDocument(); }); + + it('does not show seer when not included in formData', function () { + const formData: CheckoutFormData = { + plan: 'am3_team_auf', + reserved: { + errors: 100000, + attachments: 25, + replays: 50, + spans: 10_000_000, + monitorSeats: 1, + profileDuration: 0, + profileDurationUI: 0, + uptime: 1, + }, + onDemandMaxSpend: 5000, + }; + + render( + + ); + + expect(screen.queryByTestId('seer-reserved')).not.toBeInTheDocument(); + }); }); diff --git a/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx b/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx index 59f4f21a45ea23..4aa0ad29a45766 100644 --- a/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx +++ b/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx @@ -46,9 +46,6 @@ function CheckoutOverviewV2({activePlan, formData, onUpdate: _onUpdate}: Props) [formData.onDemandMaxSpend] ); - // const hasSeerEnabled = !!formData.seerEnabled; - // const hasSeerFeature = organization.features.includes('seer-billing'); - const renderPlanDetails = () => { return ( @@ -72,56 +69,6 @@ function CheckoutOverviewV2({activePlan, formData, onUpdate: _onUpdate}: Props) ); }; - // const renderAdditionalFeatureSummary = ({ - // featureKey, - // featureEnabled, - // featureAvailable, - // title, - // tooltipTitle, - // priceCents, - // }: { - // featureAvailable: boolean; - // featureEnabled: boolean; - // featureKey: string; - // priceCents: number; - // title: string; - // tooltipTitle: string; - // }) => { - // return ( - // featureAvailable && - // featureEnabled && ( - // - // - // - //
- // - // {title} - //    - // <QuestionTooltip size="xs" title={tooltipTitle} /> - // - //
- //
- // - // {`+${utils.displayPrice({cents: priceCents})}/mo`} - // Additional usage billed separately - // - //
- //
- // ) - // ); - // }; - - // const renderSeerSummary = () => { - // return renderAdditionalFeatureSummary({ - // featureKey: 'seer', - // featureEnabled: hasSeerEnabled, - // featureAvailable: hasSeerFeature, - // title: t('Sentry AI Agent'), - // tooltipTitle: t('Additional Seer information.'), - // priceCents: SEER_MONTHLY_PRICE_CENTS, - // }); - // }; - const renderPayAsYouGoBudget = (paygBudgetTotal: number) => { return ( diff --git a/static/gsApp/views/amCheckout/steps/planSelect.tsx b/static/gsApp/views/amCheckout/steps/planSelect.tsx index a9550dd4659e26..e0fecc6855efb8 100644 --- a/static/gsApp/views/amCheckout/steps/planSelect.tsx +++ b/static/gsApp/views/amCheckout/steps/planSelect.tsx @@ -238,88 +238,6 @@ function PlanSelect({ ); }; - // const renderSeer = () => { - // return renderAdditionalFeature({ - // featureKey: 'seer', - // featureEnabled: seerIsEnabled, - // featureAvailable: hasSeerFeature, - // title: 'Add Seer, our AI Agent', - // description: 'Insights and solutions to fix bugs faster', - // icon: , - // priceCents: SEER_MONTHLY_PRICE_CENTS, - // featureItems: ['Root Cause Analysis', 'Autofix PRs', 'AI Issue Priority'], - // tooltipTitle: t('Additional Seer information.'), - // isNew: true, - // setFeatureEnabled: setSeerIsEnabled, - // }); - // }; - - // const renderAdditionalFeature = ({ - // featureKey, - // featureEnabled, - // featureAvailable, - // title, - // description, - // icon, - // priceCents, - // featureItems, - // tooltipTitle, - // isNew = false, - // setFeatureEnabled, - // }: { - // description: string; - // featureAvailable: boolean; - // featureEnabled: boolean; - // featureItems: string[]; - // featureKey: string; - // icon: React.ReactNode; - // priceCents: number; - // setFeatureEnabled: (enabled: boolean) => void; - // title: string; - // tooltipTitle: string; - // isNew?: boolean; - // }) => { - // return ( - // featureAvailable && ( - // - // - // - // {icon} - // {title} - // {isNew && } - // - // {description} - // - // {featureItems.map((item, index) => ( - // {item} - // ))} - // - // - // - // - //
- // Extra usage requires PAYG - //
- //
- //
- // ) - // ); - // }; - const renderFooter = () => { const bizPlanContent = getContentForPlan('business', checkoutTier); let missingFeatures: string[] = []; diff --git a/static/gsApp/views/amCheckout/utils.tsx b/static/gsApp/views/amCheckout/utils.tsx index 258a84493610c1..b749be693a6f0f 100644 --- a/static/gsApp/views/amCheckout/utils.tsx +++ b/static/gsApp/views/amCheckout/utils.tsx @@ -249,8 +249,6 @@ export function getReservedPriceCents({ Object.entries(selectedProducts ?? {}).forEach(([apiName, selectedProductData]) => { if (selectedProductData.enabled) { - // TODO: replace this to use the pricing schedule once that's serialized on availableReservedBudgetTypes - // This way, we can handle both annual and monthly prices const budgetTypeInfo = plan.availableReservedBudgetTypes[apiName as ReservedBudgetCategoryType]; if (budgetTypeInfo) { From 2a29414a168c09b970cd4f79fda54eec2b922ba5 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Fri, 16 May 2025 16:18:16 -0400 Subject: [PATCH 7/9] more cleanup --- static/gsApp/views/amCheckout/steps/productSelect.tsx | 8 ++++++-- .../views/amCheckout/steps/reviewAndConfirm.spec.tsx | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/static/gsApp/views/amCheckout/steps/productSelect.tsx b/static/gsApp/views/amCheckout/steps/productSelect.tsx index dc559e7b6737a4..adaf9f3a2d4298 100644 --- a/static/gsApp/views/amCheckout/steps/productSelect.tsx +++ b/static/gsApp/views/amCheckout/steps/productSelect.tsx @@ -44,8 +44,12 @@ function ProductSelect({ icon: , color: theme.pink400 as Color, gradientEndColor: theme.pink100 as Color, - description: 'Detect and fix issues faster with our AI debugging agent.', - features: ['Issue scan', 'Root cause analysis', 'Solution and code changes'], + description: t('Detect and fix issues faster with our AI debugging agent.'), + features: [ + t('Issue scan'), + t('Root cause analysis'), + t('Solution and code changes'), + ], }, }; const billingInterval = utils.getShortInterval(activePlan.billingInterval); diff --git a/static/gsApp/views/amCheckout/steps/reviewAndConfirm.spec.tsx b/static/gsApp/views/amCheckout/steps/reviewAndConfirm.spec.tsx index a08d4ca5b3eccd..1aaca6e623502f 100644 --- a/static/gsApp/views/amCheckout/steps/reviewAndConfirm.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/reviewAndConfirm.spec.tsx @@ -293,7 +293,7 @@ describe('AmCheckout > ReviewAndConfirm', function () { ) ); - // TODO: Add seer analytics + // TODO(seer): Add seer analytics expect(trackGetsentryAnalytics).toHaveBeenCalledWith('checkout.upgrade', { organization, subscription, From db327348c396736d31621ecd3873ed9a9f520761 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Fri, 16 May 2025 17:16:07 -0400 Subject: [PATCH 8/9] tests + pre-am3 budget fix --- .../views/amCheckout/checkoutOverviewV2.tsx | 6 +- .../amCheckout/steps/productSelect.spec.tsx | 150 ++++++++++++++++++ .../views/amCheckout/steps/productSelect.tsx | 20 ++- .../getsentry-test/fixtures/reservedBudget.ts | 1 + 4 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 static/gsApp/views/amCheckout/steps/productSelect.spec.tsx diff --git a/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx b/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx index 4aa0ad29a45766..a5738647527e57 100644 --- a/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx +++ b/static/gsApp/views/amCheckout/checkoutOverviewV2.tsx @@ -143,7 +143,7 @@ function CheckoutOverviewV2({activePlan, formData, onUpdate: _onUpdate}: Props) key={budgetTypeInfo.apiName} data-test-id={`${budgetTypeInfo.apiName}-reserved`} > - + {toTitleCase(budgetTypeInfo.productName)} ` display: flex; gap: ${space(0.5)}; align-items: center; - color: ${p => p.theme.subText}; + color: ${p => (p.isIndividualProduct ? p.theme.textColor : p.theme.subText)}; `; const Section = styled(PanelChild)` diff --git a/static/gsApp/views/amCheckout/steps/productSelect.spec.tsx b/static/gsApp/views/amCheckout/steps/productSelect.spec.tsx new file mode 100644 index 00000000000000..70e65b03b0cf27 --- /dev/null +++ b/static/gsApp/views/amCheckout/steps/productSelect.spec.tsx @@ -0,0 +1,150 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {RouteComponentPropsFixture} from 'sentry-fixture/routeComponentPropsFixture'; + +import {BillingConfigFixture} from 'getsentry-test/fixtures/billingConfig'; +import {SeerReservedBudgetFixture} from 'getsentry-test/fixtures/reservedBudget'; +import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; +import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary'; + +import SubscriptionStore from 'getsentry/stores/subscriptionStore'; +import {PlanTier} from 'getsentry/types'; +import AMCheckout from 'getsentry/views/amCheckout/'; + +describe('ProductSelect', function () { + const api = new MockApiClient(); + const organization = OrganizationFixture({features: ['seer-billing']}); + const subscription = SubscriptionFixture({organization}); + const params = {}; + + beforeEach(function () { + SubscriptionStore.set(organization.slug, subscription); + + MockApiClient.addMockResponse({ + url: `/subscriptions/${organization.slug}/`, + method: 'GET', + body: {}, + }); + MockApiClient.addMockResponse({ + url: `/customers/${organization.slug}/billing-config/`, + method: 'GET', + body: BillingConfigFixture(PlanTier.AM3), + }); + MockApiClient.addMockResponse({ + method: 'POST', + url: '/_experiment/log_exposure/', + body: {}, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/promotions/trigger-check/`, + method: 'POST', + body: {}, + }); + MockApiClient.addMockResponse({ + url: `/customers/${organization.slug}/plan-migrations/?applied=0`, + method: 'GET', + body: {}, + }); + }); + + it('renders', async function () { + const freeSubscription = SubscriptionFixture({ + organization, + plan: 'am3_f', + isFree: true, + }); + SubscriptionStore.set(organization.slug, freeSubscription); + + render( + , + {organization} + ); + + expect(await screen.findByTestId('body-choose-your-plan')).toBeInTheDocument(); + expect(screen.getByTestId('product-option-seer')).toBeInTheDocument(); + expect(screen.getByText('$20/mo')).toBeInTheDocument(); + expect(screen.getByTestId('footer-choose-your-plan')).toBeInTheDocument(); + }); + + it('renders with correct monthly price for products', async function () { + render( + , + {organization} + ); + + expect(await screen.findByTestId('product-option-seer')).toHaveTextContent('$20/mo'); + }); + + it('renders with correct annual price for products', async function () { + const annualSubscription = SubscriptionFixture({ + organization, + plan: 'am3_team_auf', + }); + SubscriptionStore.set(organization.slug, annualSubscription); + + render( + , + {organization} + ); + + expect(await screen.findByTestId('product-option-seer')).toHaveTextContent('$216/yr'); + }); + + it('renders with product selected based on current subscription', async function () { + subscription.reservedBudgets = [SeerReservedBudgetFixture({id: '2'})]; + SubscriptionStore.set(organization.slug, subscription); + + render( + , + {organization} + ); + + expect(await screen.findByTestId('product-option-seer')).toHaveTextContent( + 'Added to plan' + ); + + subscription.reservedBudgets = []; // clear + }); + + it('can enable and disable products', async function () { + render( + , + {organization} + ); + + const seerProduct = await screen.findByTestId('product-option-seer'); + const seerButton = within(seerProduct).getByRole('button'); + expect(seerButton).toHaveTextContent('$20/mo'); + await userEvent.click(seerButton); + expect(seerButton).toHaveTextContent('Added to plan'); + }); +}); diff --git a/static/gsApp/views/amCheckout/steps/productSelect.tsx b/static/gsApp/views/amCheckout/steps/productSelect.tsx index adaf9f3a2d4298..08149a34578232 100644 --- a/static/gsApp/views/amCheckout/steps/productSelect.tsx +++ b/static/gsApp/views/amCheckout/steps/productSelect.tsx @@ -65,7 +65,11 @@ function ProductSelect({ } return ( - + - {t('Extra usage requires PAYG ')} + {tct('Extra usage requires [budgetTerm] ', { + budgetTerm: + activePlan.budgetTerm === 'pay-as-you-go' + ? 'PAYG' + : activePlan.budgetTerm, + })} Date: Fri, 16 May 2025 17:18:52 -0400 Subject: [PATCH 9/9] test product rendering length --- .../amCheckout/steps/productSelect.spec.tsx | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/static/gsApp/views/amCheckout/steps/productSelect.spec.tsx b/static/gsApp/views/amCheckout/steps/productSelect.spec.tsx index 70e65b03b0cf27..3bf7bd1270c8b5 100644 --- a/static/gsApp/views/amCheckout/steps/productSelect.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/productSelect.spec.tsx @@ -12,11 +12,12 @@ import AMCheckout from 'getsentry/views/amCheckout/'; describe('ProductSelect', function () { const api = new MockApiClient(); - const organization = OrganizationFixture({features: ['seer-billing']}); + const organization = OrganizationFixture({}); const subscription = SubscriptionFixture({organization}); const params = {}; beforeEach(function () { + organization.features = ['seer-billing']; SubscriptionStore.set(organization.slug, subscription); MockApiClient.addMockResponse({ @@ -67,10 +68,28 @@ describe('ProductSelect', function () { expect(await screen.findByTestId('body-choose-your-plan')).toBeInTheDocument(); expect(screen.getByTestId('product-option-seer')).toBeInTheDocument(); + expect(screen.getAllByTestId(/product-option/)).toHaveLength(1); expect(screen.getByText('$20/mo')).toBeInTheDocument(); expect(screen.getByTestId('footer-choose-your-plan')).toBeInTheDocument(); }); + it('does not render products if flags are missing', async function () { + organization.features = []; + render( + , + {organization} + ); + + expect(await screen.findByTestId('body-choose-your-plan')).toBeInTheDocument(); + expect(screen.queryAllByTestId(/product-option/)).toHaveLength(0); + }); + it('renders with correct monthly price for products', async function () { render(