import {Component, Fragment} from 'react'; import styled from '@emotion/styled'; import {Tag} from 'sentry/components/core/badge/tag'; import Panel from 'sentry/components/panels/panel'; 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 {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'; import {getPlanCategoryName} from 'getsentry/utils/dataCategory'; import formatCurrency from 'getsentry/utils/formatCurrency'; import { showChurnDiscount, showSubscriptionDiscount, } from 'getsentry/utils/promotionUtils'; import type {CheckoutFormData} from 'getsentry/views/amCheckout/types'; import * as utils from 'getsentry/views/amCheckout/utils'; import { getTotalBudget, hasOnDemandBudgetsFeature, } from 'getsentry/views/onDemandBudgets/utils'; type Props = { activePlan: Plan; billingConfig: BillingConfig; formData: CheckoutFormData; onUpdate: (data: any) => void; organization: Organization; subscription: Subscription; discountInfo?: Promotion['discountInfo']; }; class CheckoutOverview extends Component { get shortInterval() { const {activePlan} = this.props; return utils.getShortInterval(activePlan.billingInterval); } get toggledInterval() { const {activePlan} = this.props; return activePlan.billingInterval === MONTHLY ? ANNUAL : MONTHLY; } get nextPlan() { const {formData, billingConfig} = this.props; const basePlan = formData.plan.replace('_auf', ''); return billingConfig.planList.find( plan => plan.id.indexOf(basePlan) === 0 && plan.billingInterval === this.toggledInterval ); } handleChange = () => { const {onUpdate} = this.props; if (this.nextPlan) { onUpdate({ plan: this.nextPlan.id, }); } }; renderDataOptions = () => { const {formData, activePlan} = this.props; return activePlan.checkoutCategories.map(category => { const eventBucket = utils.getBucket({ // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message events: formData.reserved[category], // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message buckets: activePlan.planCategories[category], }); const price = utils.displayPrice({cents: eventBucket.price}); return (
{getPlanCategoryName({plan: activePlan, category})} { // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message formatReservedWithUnits(formData.reserved[category], category) }
{eventBucket.price === 0 ? ( {t('included')} ) : ( {`${price}/${this.shortInterval}`} )}
); }); }; renderOnDemand() { const {formData, organization, subscription, activePlan} = this.props; const {onDemandBudget} = formData; let onDemandMaxSpend = formData.onDemandMaxSpend; const displayNewOnDemandBudgetsUI = hasOnDemandBudgetsFeature( organization, subscription ); if (onDemandBudget && displayNewOnDemandBudgetsUI) { onDemandMaxSpend = getTotalBudget(onDemandBudget); } if (!onDemandMaxSpend || onDemandMaxSpend <= 0) { return null; } let title = t('On-Demand'); if (displayNewOnDemandBudgetsUI) { if (onDemandBudget) { if (onDemandBudget.budgetMode === OnDemandBudgetMode.SHARED) { title = t('Shared On-Demand'); } if (onDemandBudget.budgetMode === OnDemandBudgetMode.PER_CATEGORY) { title = t('Per-Category On-Demand'); } } } const details: React.ReactNode[] = []; if ( !displayNewOnDemandBudgetsUI || (onDemandBudget && onDemandBudget.budgetMode === OnDemandBudgetMode.SHARED) ) { const onDemandPrice = utils.displayPrice({cents: onDemandMaxSpend}); details.push( {tct('up to [onDemandPrice]/mo', {onDemandPrice})} ); } else if (onDemandBudget) { activePlan.onDemandCategories.forEach(category => { // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message if (onDemandBudget.budgets[category]) { details.push( {getPlanCategoryName({plan: activePlan, category})} {tct('up to [onDemandPrice]/mo', { onDemandPrice: utils.displayPrice({ // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message cents: onDemandBudget.budgets[category], }), })} ); } }); } return (
{title}
{t('enabled')} {details}
); } renderDetailItems = () => { const {activePlan, discountInfo} = this.props; const planName = activePlan.name; const showSubscriptionDiscountInfo = showSubscriptionDiscount({activePlan, discountInfo}) && discountInfo; let price = activePlan.basePrice; const originalTotal = utils.displayPrice({cents: activePlan.basePrice}); if (showSubscriptionDiscountInfo) { price = utils.getDiscountedPrice({ basePrice: price, discountType: discountInfo.discountType, amount: discountInfo.amount, creditCategory: discountInfo.creditCategory, }); } const basePrice = utils.displayPrice({cents: price}); return (
{t('Plan Type')} {showSubscriptionDiscountInfo ? ( {planName} ) : ( {planName} )} {showSubscriptionDiscountInfo && ( {`${discountInfo.durationText}*`} )}
{showSubscriptionDiscountInfo && ( {t('Promo Price')} )} {`${basePrice}/${this.shortInterval}`} {showSubscriptionDiscountInfo && ( {`${originalTotal}/${this.shortInterval}`} {tct('([percentOff]% off)', { percentOff: discountInfo.amount / 100, })} )}
{this.renderDataOptions()} {this.renderOnDemand()}
); }; render() { const {formData, activePlan, discountInfo} = this.props; let discountData = {}; const showDiscount = (showSubscriptionDiscount({activePlan, discountInfo}) || showChurnDiscount({activePlan, discountInfo})) && discountInfo; if (showDiscount) { discountData = { discountType: discountInfo.discountType, amount: discountInfo.amount, maxDiscount: discountInfo.maxCentsPerPeriod, creditCategory: discountInfo.creditCategory, }; } const reservedTotal = utils.getReservedTotal({ ...formData, plan: activePlan, ...discountData, }); const originalTotal = utils.getReservedTotal({ ...formData, plan: activePlan, }); const billingInterval = discountInfo?.billingInterval === 'monthly' ? 'Months' : 'Years'; const showChurnDiscountInfo = showChurnDiscount({activePlan, discountInfo}) && discountInfo; const {onDemandBudget} = formData; return ( {showChurnDiscountInfo && ( {t( 'Promotional Price for %s %s*', discountInfo.billingPeriods, billingInterval )} )} {tct('[name] Plan', {name: activePlan.name})}
$
{reservedTotal}
{`/${this.shortInterval}`}
{onDemandBudget && getTotalBudget(onDemandBudget) > 0 ? ( {tct('+ On-Demand charges up to [amount][break] based on usage', { amount: `${formatCurrency(getTotalBudget(onDemandBudget))}/mo`, break:
, })}
) : ( // Placeholder to avoid jumping when on-demand charges are added
)}
{showChurnDiscountInfo && ( {tct('Current Total Price: [originalTotal] ([amount]% off*)', { originalTotal: ${originalTotal}/mo, amount: discountInfo.amount / 100, })} )} {this.renderDetailItems()} ); } } const StyledPanel = styled(Panel)` display: grid; grid-template-rows: repeat(2, auto); gap: ${space(3)}; padding: ${space(4)} ${space(3)} ${space(3)}; `; const OverviewHeading = styled('div')` display: grid; grid-template-rows: repeat(2, auto); gap: ${space(1.5)}; font-size: ${p => p.theme.fontSizeExtraLarge}; align-items: center; text-align: center; justify-items: center; `; const PlanName = styled('div')` font-weight: 600; `; const TotalPrice = styled('div')` display: inline-grid; grid-template-columns: repeat(3, auto); font-size: 56px; `; const Currency = styled('div')` font-size: 32px; padding-top: ${space(1)}; `; const BillingInterval = styled('div')` font-size: ${p => p.theme.headerFontSize}; padding-bottom: ${space(1.5)}; align-self: end; `; const OnDemandAdditionalCost = styled('div')` font-size: ${p => p.theme.fontSizeSmall}; `; const DetailItems = styled(PanelBody)` color: ${p => p.theme.subText}; font-size: ${p => p.theme.fontSizeLarge}; `; const DetailItem = styled('div')` display: grid; grid-template-columns: 1fr auto; gap: ${space(0.5)}; align-items: center; padding: ${space(2)} 0; border-top: 1px solid ${p => p.theme.innerBorder}; `; const DetailTitle = styled('div')<{noBottomMargin?: boolean}>` text-transform: uppercase; font-size: ${p => p.theme.fontSizeSmall}; font-weight: 600; color: ${p => p.theme.gray300}; margin-top: ${space(0.25)}; ${p => !p.noBottomMargin && `margin-bottom: ${space(1)};`} `; const PromoDetailTitle = styled(DetailTitle)` display: flex; justify-content: end; `; const OnDemandDetailItem = styled(DetailItem)` align-items: start; `; const OnDemandDetailItems = styled('div')` display: grid; grid-template-columns: 1fr auto; gap: ${space(1)}; column-gap: ${space(1)}; grid-column: 1 / -1; `; const OnDemandPrice = styled('div')` text-align: right; `; const OriginalPrice = styled('div')` color: ${p => p.theme.gray300}; text-decoration: line-through; `; const DetailPrice = styled('div')` justify-self: end; color: ${p => p.theme.textColor}; display: flex; justify-content: end; `; const PriceContainer = styled('div')` display: flex; flex-direction: column; `; const DiscountWrapper = styled('div')` display: flex; align-items: center; gap: ${space(0.5)}; font-size: ${p => p.theme.fontSizeSmall}; color: ${p => p.theme.gray300}; `; const DurationText = styled('div')` font-size: ${p => p.theme.fontSizeSmall}; `; const ProminantPlanName = styled('span')` font-weight: 500; font-size: ${p => p.theme.fontSizeExtraLarge}; color: ${p => p.theme.gray500}; `; const ChurnPromoText = styled('span')` font-size: ${p => p.theme.fontSizeLarge}; color: ${p => p.theme.gray300}; font-weight: bold; `; const DiscountText = styled(ChurnPromoText)` text-decoration: line-through; `; export default CheckoutOverview;