usageCard.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import styled from '@emotion/styled';
  2. import color from 'color';
  3. import {Tooltip} from 'sentry/components/tooltip';
  4. import {CHART_PALETTE} from 'sentry/constants/chartPalette';
  5. import {IconInfo} from 'sentry/icons';
  6. import {t} from 'sentry/locale';
  7. import {space} from 'sentry/styles/space';
  8. import type {Organization} from 'sentry/types/organization';
  9. import {formatPercentage} from 'sentry/utils/number/formatPercentage';
  10. import {PlanTier, type Subscription} from 'getsentry/types';
  11. import formatCurrency from 'getsentry/utils/formatCurrency';
  12. import {roundUpToNearestDollar} from 'getsentry/utils/roundUpToNearestDollar';
  13. import {
  14. getTotalBudget,
  15. hasOnDemandBudgetsFeature,
  16. parseOnDemandBudgetsFromSubscription,
  17. } from 'getsentry/views/onDemandBudgets/utils';
  18. import {
  19. calculateTotalSpend,
  20. shouldSeeSpendVisibility,
  21. } from 'getsentry/views/subscriptionPage/utils';
  22. const COLORS = {
  23. prepaid: CHART_PALETTE[5]![0]!,
  24. ondemand: CHART_PALETTE[5]![1]!,
  25. } as const;
  26. interface UsageCardProps {
  27. organization: Organization;
  28. subscription: Subscription;
  29. }
  30. export function UsageCard({subscription, organization}: UsageCardProps) {
  31. const intervalPrice = subscription.customPrice
  32. ? subscription.customPrice
  33. : subscription.planDetails?.price;
  34. if (!intervalPrice || !shouldSeeSpendVisibility(subscription)) {
  35. return null;
  36. }
  37. const hasOnDemand =
  38. hasOnDemandBudgetsFeature(organization, subscription) ||
  39. subscription.planTier === PlanTier.AM3;
  40. const showOnDemand = hasOnDemand && subscription.onDemandMaxSpend !== 0;
  41. const onDemandBudgets = parseOnDemandBudgetsFromSubscription(subscription);
  42. const onDemandTotalBudget =
  43. 'sharedMaxBudget' in onDemandBudgets
  44. ? onDemandBudgets.sharedMaxBudget
  45. : getTotalBudget(onDemandBudgets);
  46. const {prepaidTotalSpent, onDemandTotalSpent, prepaidTotalPrice} =
  47. calculateTotalSpend(subscription);
  48. const showPrepaid = prepaidTotalPrice > 0;
  49. // No reserved spend beyond the base subscription and no on-demand budgets
  50. if (!showPrepaid && !showOnDemand) {
  51. return null;
  52. }
  53. // Round the usage width to avoid half pixel artifacts
  54. // Prevent more than 100% width
  55. const prepaidPercentUsed = Math.max(
  56. 0,
  57. Math.round(Math.min((prepaidTotalSpent / prepaidTotalPrice) * 100, 100))
  58. );
  59. const prepaidPercentUnused = 100 - prepaidPercentUsed;
  60. const onDemandPercentUsed = Math.round(
  61. Math.min((onDemandTotalSpent / onDemandTotalBudget) * 100, 100)
  62. );
  63. const onDemandPercentUnused = 100 - onDemandPercentUsed;
  64. // Calculate the width of the prepaid bar relative to on demand
  65. let prepaidMaxWidth = showOnDemand && showPrepaid ? 50 : showPrepaid ? 100 : 0;
  66. if (showOnDemand && showPrepaid && prepaidTotalSpent && onDemandTotalBudget) {
  67. prepaidMaxWidth = Math.round(
  68. (prepaidTotalPrice / (prepaidTotalPrice + onDemandTotalBudget)) * 100
  69. );
  70. }
  71. return (
  72. <PlanUseBody data-test-id="usage-card">
  73. <UsageSummary
  74. style={{gridTemplateColumns: `repeat(${showOnDemand ? 3 : 2}, auto)`}}
  75. >
  76. {showPrepaid && (
  77. <SummaryWrapper>
  78. <SummaryTitleWrapper>
  79. <SummaryTitle>{t('Included In Subscription')}</SummaryTitle>
  80. <Tooltip
  81. title={t('Your reserved purchase above the base plan')}
  82. skipWrapper
  83. >
  84. <IconInfo size="xs" />
  85. </Tooltip>
  86. </SummaryTitleWrapper>
  87. <SummaryTotal>
  88. {formatCurrency(roundUpToNearestDollar(prepaidTotalPrice))}/mo
  89. </SummaryTotal>
  90. </SummaryWrapper>
  91. )}
  92. {showOnDemand && (
  93. <SummaryWrapper>
  94. <SummaryTitleWrapper>
  95. <SummaryTitle>
  96. {subscription.planTier === PlanTier.AM3
  97. ? t('Pay-as-you-go Spent')
  98. : t('On-Demand Spent')}
  99. </SummaryTitle>
  100. <Tooltip
  101. title={
  102. subscription.planTier === PlanTier.AM3
  103. ? t('Pay-as-you-go budget consumed')
  104. : t('On-Demand budget consumed')
  105. }
  106. skipWrapper
  107. >
  108. <IconInfo size="xs" />
  109. </Tooltip>
  110. </SummaryTitleWrapper>
  111. <SummaryTotal>{formatCurrency(onDemandTotalSpent)}</SummaryTotal>
  112. </SummaryWrapper>
  113. )}
  114. <SummaryWrapper data-test-id="current-monthly-spend">
  115. <SummaryTitleWrapper>
  116. <SummaryTitle>
  117. {subscription.billingInterval === 'annual'
  118. ? t('Additional Monthly Spend')
  119. : t('Current Monthly Spend')}
  120. </SummaryTitle>
  121. <Tooltip title={t('Total spend till date')} skipWrapper>
  122. <IconInfo size="xs" />
  123. </Tooltip>
  124. </SummaryTitleWrapper>
  125. <SummaryTotal>
  126. {formatCurrency(
  127. subscription.billingInterval === 'annual'
  128. ? onDemandTotalSpent
  129. : subscription.planDetails.basePrice +
  130. prepaidTotalPrice +
  131. onDemandTotalSpent
  132. )}
  133. </SummaryTotal>
  134. </SummaryWrapper>
  135. </UsageSummary>
  136. <PlanUseBarContainer>
  137. {showPrepaid && (
  138. <PlanUseBarGroup style={{width: `${prepaidMaxWidth}%`}}>
  139. {prepaidPercentUsed > 1 && (
  140. <PlanUseBar
  141. style={{
  142. width: `${prepaidPercentUsed}%`,
  143. backgroundColor: COLORS.prepaid,
  144. }}
  145. />
  146. )}
  147. {prepaidPercentUnused > 1 && (
  148. <PlanUseBar
  149. style={{
  150. width: `${prepaidPercentUnused}%`,
  151. backgroundColor: color(COLORS.prepaid).fade(0.5).string(),
  152. }}
  153. />
  154. )}
  155. </PlanUseBarGroup>
  156. )}
  157. {showOnDemand && (
  158. <PlanUseBarGroup style={{width: `${100 - prepaidMaxWidth}%`}}>
  159. {onDemandPercentUsed > 1 && (
  160. <PlanUseBar
  161. style={{
  162. width: `${onDemandPercentUsed}%`,
  163. backgroundColor: COLORS.ondemand,
  164. }}
  165. />
  166. )}
  167. {onDemandPercentUnused > 1 && (
  168. <PlanUseBar
  169. style={{
  170. width: `${onDemandPercentUnused}%`,
  171. backgroundColor: color(COLORS.ondemand).fade(0.5).string(),
  172. }}
  173. />
  174. )}
  175. </PlanUseBarGroup>
  176. )}
  177. </PlanUseBarContainer>
  178. <LegendPriceWrapper>
  179. {showPrepaid && (
  180. <LegendContainer>
  181. <LegendDot style={{backgroundColor: COLORS.prepaid}} />
  182. <div>
  183. <LegendTitle>{t('Included in Subscription')}</LegendTitle>
  184. <LegendPrice>
  185. {formatPercentage(prepaidPercentUsed / 100)} of{' '}
  186. {formatCurrency(roundUpToNearestDollar(prepaidTotalPrice))}
  187. </LegendPrice>
  188. </div>
  189. </LegendContainer>
  190. )}
  191. {showOnDemand && (
  192. <LegendContainer>
  193. <LegendDot style={{backgroundColor: COLORS.ondemand}} />
  194. <div>
  195. <LegendTitle>
  196. {subscription.planTier === PlanTier.AM3
  197. ? t('Pay-as-you-go')
  198. : t('On-Demand')}
  199. </LegendTitle>
  200. <LegendPrice>
  201. {formatCurrency(onDemandTotalSpent)} of{' '}
  202. {formatCurrency(onDemandTotalBudget)}
  203. </LegendPrice>
  204. </div>
  205. </LegendContainer>
  206. )}
  207. </LegendPriceWrapper>
  208. </PlanUseBody>
  209. );
  210. }
  211. const UsageSummary = styled('div')`
  212. display: grid;
  213. gap: ${space(1.5)};
  214. align-items: center;
  215. justify-content: flex-end;
  216. text-align: right;
  217. `;
  218. const SummaryWrapper = styled('div')`
  219. ${p => p.theme.overflowEllipsis}
  220. `;
  221. const SummaryTitleWrapper = styled('div')`
  222. display: flex;
  223. gap: ${space(0.25)};
  224. align-items: baseline;
  225. `;
  226. const SummaryTitle = styled('div')`
  227. ${p => p.theme.overflowEllipsis}
  228. `;
  229. const SummaryTotal = styled('div')`
  230. font-size: ${p => p.theme.fontSizeLarge};
  231. font-weight: 700;
  232. `;
  233. const PlanUseBody = styled('div')`
  234. display: flex;
  235. flex-direction: column;
  236. gap: ${space(0.75)};
  237. padding: 0 ${space(3)} ${space(1.5)} ${space(3)};
  238. line-height: 1.2;
  239. color: ${p => p.theme.subText};
  240. @media (min-width: ${p => p.theme.breakpoints.large}) {
  241. padding: ${space(1.5)} ${space(3)} ${space(1.5)} 0;
  242. }
  243. `;
  244. const PlanUseBarContainer = styled('div')`
  245. display: flex;
  246. height: 14px;
  247. width: 100%;
  248. overflow: hidden;
  249. gap: 2px;
  250. `;
  251. const PlanUseBarGroup = styled('div')`
  252. display: flex;
  253. gap: 2px;
  254. `;
  255. const PlanUseBar = styled('div')`
  256. height: 100%;
  257. `;
  258. const LegendPriceWrapper = styled('div')`
  259. display: flex;
  260. gap: ${space(1)};
  261. `;
  262. const LegendDot = styled('div')`
  263. border-radius: 100%;
  264. width: 10px;
  265. height: 10px;
  266. `;
  267. const LegendContainer = styled('div')`
  268. display: grid;
  269. grid-template-columns: min-content 1fr;
  270. gap: ${space(1)};
  271. align-items: baseline;
  272. `;
  273. const LegendTitle = styled('div')`
  274. font-weight: 700;
  275. font-size: ${p => p.theme.fontSizeSmall};
  276. `;
  277. const LegendPrice = styled('div')`
  278. font-size: ${p => p.theme.fontSizeSmall};
  279. font-variant-numeric: tabular-nums;
  280. `;