checkoutOverview.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. import {Component, Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Tag} from 'sentry/components/core/badge/tag';
  4. import Panel from 'sentry/components/panels/panel';
  5. import PanelBody from 'sentry/components/panels/panelBody';
  6. import {t, tct} from 'sentry/locale';
  7. import {space} from 'sentry/styles/space';
  8. import type {Organization} from 'sentry/types/organization';
  9. import {ANNUAL, MONTHLY} from 'getsentry/constants';
  10. import type {BillingConfig, Plan, Promotion, Subscription} from 'getsentry/types';
  11. import {OnDemandBudgetMode} from 'getsentry/types';
  12. import {formatReservedWithUnits} from 'getsentry/utils/billing';
  13. import {getPlanCategoryName} from 'getsentry/utils/dataCategory';
  14. import formatCurrency from 'getsentry/utils/formatCurrency';
  15. import {
  16. showChurnDiscount,
  17. showSubscriptionDiscount,
  18. } from 'getsentry/utils/promotionUtils';
  19. import type {CheckoutFormData} from 'getsentry/views/amCheckout/types';
  20. import * as utils from 'getsentry/views/amCheckout/utils';
  21. import {
  22. getTotalBudget,
  23. hasOnDemandBudgetsFeature,
  24. } from 'getsentry/views/onDemandBudgets/utils';
  25. type Props = {
  26. activePlan: Plan;
  27. billingConfig: BillingConfig;
  28. formData: CheckoutFormData;
  29. onUpdate: (data: any) => void;
  30. organization: Organization;
  31. subscription: Subscription;
  32. discountInfo?: Promotion['discountInfo'];
  33. };
  34. class CheckoutOverview extends Component<Props> {
  35. get shortInterval() {
  36. const {activePlan} = this.props;
  37. return utils.getShortInterval(activePlan.billingInterval);
  38. }
  39. get toggledInterval() {
  40. const {activePlan} = this.props;
  41. return activePlan.billingInterval === MONTHLY ? ANNUAL : MONTHLY;
  42. }
  43. get nextPlan() {
  44. const {formData, billingConfig} = this.props;
  45. const basePlan = formData.plan.replace('_auf', '');
  46. return billingConfig.planList.find(
  47. plan =>
  48. plan.id.indexOf(basePlan) === 0 && plan.billingInterval === this.toggledInterval
  49. );
  50. }
  51. handleChange = () => {
  52. const {onUpdate} = this.props;
  53. if (this.nextPlan) {
  54. onUpdate({
  55. plan: this.nextPlan.id,
  56. });
  57. }
  58. };
  59. renderDataOptions = () => {
  60. const {formData, activePlan} = this.props;
  61. return activePlan.checkoutCategories.map(category => {
  62. const eventBucket = utils.getBucket({
  63. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  64. events: formData.reserved[category],
  65. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  66. buckets: activePlan.planCategories[category],
  67. });
  68. const price = utils.displayPrice({cents: eventBucket.price});
  69. return (
  70. <DetailItem key={category} data-test-id={category}>
  71. <div>
  72. <DetailTitle>{getPlanCategoryName({plan: activePlan, category})}</DetailTitle>
  73. {
  74. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  75. formatReservedWithUnits(formData.reserved[category], category)
  76. }
  77. </div>
  78. {eventBucket.price === 0 ? (
  79. <Tag>{t('included')}</Tag>
  80. ) : (
  81. <DetailPrice>{`${price}/${this.shortInterval}`}</DetailPrice>
  82. )}
  83. </DetailItem>
  84. );
  85. });
  86. };
  87. renderOnDemand() {
  88. const {formData, organization, subscription, activePlan} = this.props;
  89. const {onDemandBudget} = formData;
  90. let onDemandMaxSpend = formData.onDemandMaxSpend;
  91. const displayNewOnDemandBudgetsUI = hasOnDemandBudgetsFeature(
  92. organization,
  93. subscription
  94. );
  95. if (onDemandBudget && displayNewOnDemandBudgetsUI) {
  96. onDemandMaxSpend = getTotalBudget(onDemandBudget);
  97. }
  98. if (!onDemandMaxSpend || onDemandMaxSpend <= 0) {
  99. return null;
  100. }
  101. let title = t('On-Demand');
  102. if (displayNewOnDemandBudgetsUI) {
  103. if (onDemandBudget) {
  104. if (onDemandBudget.budgetMode === OnDemandBudgetMode.SHARED) {
  105. title = t('Shared On-Demand');
  106. }
  107. if (onDemandBudget.budgetMode === OnDemandBudgetMode.PER_CATEGORY) {
  108. title = t('Per-Category On-Demand');
  109. }
  110. }
  111. }
  112. const details: React.ReactNode[] = [];
  113. if (
  114. !displayNewOnDemandBudgetsUI ||
  115. (onDemandBudget && onDemandBudget.budgetMode === OnDemandBudgetMode.SHARED)
  116. ) {
  117. const onDemandPrice = utils.displayPrice({cents: onDemandMaxSpend});
  118. details.push(
  119. <Fragment key="shared-ondemand">
  120. {tct('up to [onDemandPrice]/mo', {onDemandPrice})}
  121. </Fragment>
  122. );
  123. } else if (onDemandBudget) {
  124. activePlan.onDemandCategories.forEach(category => {
  125. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  126. if (onDemandBudget.budgets[category]) {
  127. details.push(
  128. <Fragment key={`${category}-per-category-ondemand`}>
  129. {getPlanCategoryName({plan: activePlan, category})}
  130. <OnDemandPrice>
  131. {tct('up to [onDemandPrice]/mo', {
  132. onDemandPrice: utils.displayPrice({
  133. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  134. cents: onDemandBudget.budgets[category],
  135. }),
  136. })}
  137. </OnDemandPrice>
  138. </Fragment>
  139. );
  140. }
  141. });
  142. }
  143. return (
  144. <OnDemandDetailItem key="ondemand" data-test-id="ondemand">
  145. <div>
  146. <DetailTitle>{title}</DetailTitle>
  147. </div>
  148. <Tag>{t('enabled')}</Tag>
  149. <OnDemandDetailItems>{details}</OnDemandDetailItems>
  150. </OnDemandDetailItem>
  151. );
  152. }
  153. renderDetailItems = () => {
  154. const {activePlan, discountInfo} = this.props;
  155. const planName = activePlan.name;
  156. const showSubscriptionDiscountInfo =
  157. showSubscriptionDiscount({activePlan, discountInfo}) && discountInfo;
  158. let price = activePlan.basePrice;
  159. const originalTotal = utils.displayPrice({cents: activePlan.basePrice});
  160. if (showSubscriptionDiscountInfo) {
  161. price = utils.getDiscountedPrice({
  162. basePrice: price,
  163. discountType: discountInfo.discountType,
  164. amount: discountInfo.amount,
  165. creditCategory: discountInfo.creditCategory,
  166. });
  167. }
  168. const basePrice = utils.displayPrice({cents: price});
  169. return (
  170. <Fragment>
  171. <DetailItem key="plan" data-test-id="plan">
  172. <div>
  173. <DetailTitle noBottomMargin={!!discountInfo}>{t('Plan Type')}</DetailTitle>
  174. {showSubscriptionDiscountInfo ? (
  175. <ProminantPlanName>{planName}</ProminantPlanName>
  176. ) : (
  177. <Fragment>{planName}</Fragment>
  178. )}
  179. {showSubscriptionDiscountInfo && (
  180. <DurationText>{`${discountInfo.durationText}*`}</DurationText>
  181. )}
  182. </div>
  183. <PriceContainer>
  184. {showSubscriptionDiscountInfo && (
  185. <PromoDetailTitle noBottomMargin>{t('Promo Price')}</PromoDetailTitle>
  186. )}
  187. <DetailPrice>{`${basePrice}/${this.shortInterval}`}</DetailPrice>
  188. {showSubscriptionDiscountInfo && (
  189. <DiscountWrapper>
  190. <OriginalPrice>{`${originalTotal}/${this.shortInterval}`}</OriginalPrice>
  191. {tct('([percentOff]% off)', {
  192. percentOff: discountInfo.amount / 100,
  193. })}
  194. </DiscountWrapper>
  195. )}
  196. </PriceContainer>
  197. </DetailItem>
  198. {this.renderDataOptions()}
  199. {this.renderOnDemand()}
  200. </Fragment>
  201. );
  202. };
  203. render() {
  204. const {formData, activePlan, discountInfo} = this.props;
  205. let discountData = {};
  206. const showDiscount =
  207. (showSubscriptionDiscount({activePlan, discountInfo}) ||
  208. showChurnDiscount({activePlan, discountInfo})) &&
  209. discountInfo;
  210. if (showDiscount) {
  211. discountData = {
  212. discountType: discountInfo.discountType,
  213. amount: discountInfo.amount,
  214. maxDiscount: discountInfo.maxCentsPerPeriod,
  215. creditCategory: discountInfo.creditCategory,
  216. };
  217. }
  218. const reservedTotal = utils.getReservedTotal({
  219. ...formData,
  220. plan: activePlan,
  221. ...discountData,
  222. });
  223. const originalTotal = utils.getReservedTotal({
  224. ...formData,
  225. plan: activePlan,
  226. });
  227. const billingInterval =
  228. discountInfo?.billingInterval === 'monthly' ? 'Months' : 'Years';
  229. const showChurnDiscountInfo =
  230. showChurnDiscount({activePlan, discountInfo}) && discountInfo;
  231. const {onDemandBudget} = formData;
  232. return (
  233. <StyledPanel>
  234. <OverviewHeading>
  235. {showChurnDiscountInfo && (
  236. <ChurnPromoText>
  237. {t(
  238. 'Promotional Price for %s %s*',
  239. discountInfo.billingPeriods,
  240. billingInterval
  241. )}
  242. </ChurnPromoText>
  243. )}
  244. <PlanName>{tct('[name] Plan', {name: activePlan.name})}</PlanName>
  245. <div>
  246. <TotalPrice>
  247. <Currency>$</Currency>
  248. <div>{reservedTotal}</div>
  249. <BillingInterval>{`/${this.shortInterval}`}</BillingInterval>
  250. </TotalPrice>
  251. {onDemandBudget && getTotalBudget(onDemandBudget) > 0 ? (
  252. <OnDemandAdditionalCost data-test-id="on-demand-additional-cost">
  253. {tct('+ On-Demand charges up to [amount][break] based on usage', {
  254. amount: `${formatCurrency(getTotalBudget(onDemandBudget))}/mo`,
  255. break: <br />,
  256. })}
  257. </OnDemandAdditionalCost>
  258. ) : (
  259. // Placeholder to avoid jumping when on-demand charges are added
  260. <div style={{height: 33}} />
  261. )}
  262. </div>
  263. {showChurnDiscountInfo && (
  264. <ChurnPromoText>
  265. {tct('Current Total Price: [originalTotal] ([amount]% off*)', {
  266. originalTotal: <DiscountText>${originalTotal}/mo</DiscountText>,
  267. amount: discountInfo.amount / 100,
  268. })}
  269. </ChurnPromoText>
  270. )}
  271. </OverviewHeading>
  272. <DetailItems>{this.renderDetailItems()}</DetailItems>
  273. </StyledPanel>
  274. );
  275. }
  276. }
  277. const StyledPanel = styled(Panel)`
  278. display: grid;
  279. grid-template-rows: repeat(2, auto);
  280. gap: ${space(3)};
  281. padding: ${space(4)} ${space(3)} ${space(3)};
  282. `;
  283. const OverviewHeading = styled('div')`
  284. display: grid;
  285. grid-template-rows: repeat(2, auto);
  286. gap: ${space(1.5)};
  287. font-size: ${p => p.theme.fontSizeExtraLarge};
  288. align-items: center;
  289. text-align: center;
  290. justify-items: center;
  291. `;
  292. const PlanName = styled('div')`
  293. font-weight: 600;
  294. `;
  295. const TotalPrice = styled('div')`
  296. display: inline-grid;
  297. grid-template-columns: repeat(3, auto);
  298. font-size: 56px;
  299. `;
  300. const Currency = styled('div')`
  301. font-size: 32px;
  302. padding-top: ${space(1)};
  303. `;
  304. const BillingInterval = styled('div')`
  305. font-size: ${p => p.theme.headerFontSize};
  306. padding-bottom: ${space(1.5)};
  307. align-self: end;
  308. `;
  309. const OnDemandAdditionalCost = styled('div')`
  310. font-size: ${p => p.theme.fontSizeSmall};
  311. `;
  312. const DetailItems = styled(PanelBody)`
  313. color: ${p => p.theme.subText};
  314. font-size: ${p => p.theme.fontSizeLarge};
  315. `;
  316. const DetailItem = styled('div')`
  317. display: grid;
  318. grid-template-columns: 1fr auto;
  319. gap: ${space(0.5)};
  320. align-items: center;
  321. padding: ${space(2)} 0;
  322. border-top: 1px solid ${p => p.theme.innerBorder};
  323. `;
  324. const DetailTitle = styled('div')<{noBottomMargin?: boolean}>`
  325. text-transform: uppercase;
  326. font-size: ${p => p.theme.fontSizeSmall};
  327. font-weight: 600;
  328. color: ${p => p.theme.gray300};
  329. margin-top: ${space(0.25)};
  330. ${p => !p.noBottomMargin && `margin-bottom: ${space(1)};`}
  331. `;
  332. const PromoDetailTitle = styled(DetailTitle)`
  333. display: flex;
  334. justify-content: end;
  335. `;
  336. const OnDemandDetailItem = styled(DetailItem)`
  337. align-items: start;
  338. `;
  339. const OnDemandDetailItems = styled('div')`
  340. display: grid;
  341. grid-template-columns: 1fr auto;
  342. gap: ${space(1)};
  343. column-gap: ${space(1)};
  344. grid-column: 1 / -1;
  345. `;
  346. const OnDemandPrice = styled('div')`
  347. text-align: right;
  348. `;
  349. const OriginalPrice = styled('div')`
  350. color: ${p => p.theme.gray300};
  351. text-decoration: line-through;
  352. `;
  353. const DetailPrice = styled('div')`
  354. justify-self: end;
  355. color: ${p => p.theme.textColor};
  356. display: flex;
  357. justify-content: end;
  358. `;
  359. const PriceContainer = styled('div')`
  360. display: flex;
  361. flex-direction: column;
  362. `;
  363. const DiscountWrapper = styled('div')`
  364. display: flex;
  365. align-items: center;
  366. gap: ${space(0.5)};
  367. font-size: ${p => p.theme.fontSizeSmall};
  368. color: ${p => p.theme.gray300};
  369. `;
  370. const DurationText = styled('div')`
  371. font-size: ${p => p.theme.fontSizeSmall};
  372. `;
  373. const ProminantPlanName = styled('span')`
  374. font-weight: 500;
  375. font-size: ${p => p.theme.fontSizeExtraLarge};
  376. color: ${p => p.theme.gray500};
  377. `;
  378. const ChurnPromoText = styled('span')`
  379. font-size: ${p => p.theme.fontSizeLarge};
  380. color: ${p => p.theme.gray300};
  381. font-weight: bold;
  382. `;
  383. const DiscountText = styled(ChurnPromoText)`
  384. text-decoration: line-through;
  385. `;
  386. export default CheckoutOverview;