onDemandSummary.tsx 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. import {Component} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Button, LinkButton} from 'sentry/components/button';
  4. import {Input} from 'sentry/components/core/input';
  5. import FieldGroup from 'sentry/components/forms/fieldGroup';
  6. import Panel from 'sentry/components/panels/panel';
  7. import PanelAlert from 'sentry/components/panels/panelAlert';
  8. import PanelBody from 'sentry/components/panels/panelBody';
  9. import PanelFooter from 'sentry/components/panels/panelFooter';
  10. import PanelHeader from 'sentry/components/panels/panelHeader';
  11. import {Tooltip} from 'sentry/components/tooltip';
  12. import {IconQuestion} from 'sentry/icons';
  13. import {t, tct} from 'sentry/locale';
  14. import {space} from 'sentry/styles/space';
  15. import type {Organization} from 'sentry/types/organization';
  16. import withOrganization from 'sentry/utils/withOrganization';
  17. import {openEditCreditCard} from 'getsentry/actionCreators/modal';
  18. import OnDemandPrice from 'getsentry/components/onDemandPrice';
  19. import SubscriptionStore from 'getsentry/stores/subscriptionStore';
  20. import type {Subscription} from 'getsentry/types';
  21. import {hasPerformance} from 'getsentry/utils/billing';
  22. import {listDisplayNames} from 'getsentry/utils/dataCategory';
  23. function coerceValue(value: number): number {
  24. return value / 100;
  25. }
  26. type DefaultProps = {
  27. withHeader?: boolean;
  28. withPanel?: boolean;
  29. };
  30. type Props = DefaultProps & {
  31. enabled: boolean;
  32. hasPaymentSource: boolean;
  33. organization: Organization;
  34. pricePerEvent: number;
  35. subscription: Subscription;
  36. // value is in whole cents
  37. value: number;
  38. changeOnDemand?: (cents: number) => void;
  39. error?: string | null | Error;
  40. isCheckoutStep?: boolean;
  41. onSave?: (cents: number) => void;
  42. showSave?: boolean;
  43. };
  44. type State = {
  45. initialValue: number;
  46. value: number;
  47. };
  48. class OnDemandSummary extends Component<Props, State> {
  49. static defaultProps: DefaultProps = {
  50. withHeader: true,
  51. withPanel: true,
  52. };
  53. constructor(props: Readonly<Props>) {
  54. super(props);
  55. const value = coerceValue(props.value);
  56. this.state = {
  57. initialValue: value,
  58. value,
  59. };
  60. }
  61. parseValue = (e: React.ChangeEvent<HTMLInputElement>) => {
  62. const value = parseInt(e.target.value, 10) || 0;
  63. this.setValue(value);
  64. };
  65. setValue(value: number) {
  66. const {changeOnDemand} = this.props;
  67. value = Math.max(value, 0);
  68. this.setState({value});
  69. const cents = value * 100;
  70. changeOnDemand?.(cents);
  71. }
  72. onSave = (
  73. e: React.FormEvent<HTMLFormElement> | React.MouseEvent<Element, MouseEvent>
  74. ) => {
  75. const {value} = this.state;
  76. const {onSave} = this.props;
  77. e?.preventDefault();
  78. const cents = value * 100;
  79. onSave?.(cents);
  80. this.setState({initialValue: value});
  81. };
  82. renderLabel = () => (
  83. <Label>
  84. {t('On-Demand Max Spend')}
  85. <Tooltip
  86. title={t(
  87. `On-Demand spend allows you to pay for additional data beyond your subscription's
  88. reserved event volume. Billed monthly at the end of the usage period.`
  89. )}
  90. >
  91. <LinkButton
  92. priority="link"
  93. href="https://docs.sentry.io/pricing/legacy-pricing/#on-demand-volume"
  94. icon={<IconQuestion size="xs" />}
  95. size="sm"
  96. external
  97. aria-label={t('Visit docs')}
  98. />
  99. </Tooltip>
  100. </Label>
  101. );
  102. renderNotEnabled() {
  103. const {organization} = this.props;
  104. return (
  105. <FieldGroup
  106. label={this.renderLabel()}
  107. help={t('On-Demand is not supported for your account.')}
  108. >
  109. <div>
  110. <Button to={`/settings/${organization.slug}/support/`}>
  111. {t('Contact Support')}
  112. </Button>
  113. </div>
  114. </FieldGroup>
  115. );
  116. }
  117. renderNeedsPaymentSource() {
  118. const {organization} = this.props;
  119. return (
  120. <FieldGroup
  121. label={this.renderLabel()}
  122. help={t("To enable on-demand spend, you'll need a valid credit card on file.")}
  123. >
  124. <div>
  125. <Button
  126. priority="primary"
  127. data-test-id="add-cc-card"
  128. onClick={() =>
  129. openEditCreditCard({
  130. organization,
  131. onSuccess: (data: Subscription) => {
  132. SubscriptionStore.set(organization.slug, data);
  133. },
  134. })
  135. }
  136. >
  137. {t('Add Credit Card')}
  138. </Button>
  139. </div>
  140. </FieldGroup>
  141. );
  142. }
  143. renderOnDemandInput() {
  144. const {subscription, pricePerEvent} = this.props;
  145. const {value} = this.state;
  146. const events = Math.trunc((value * 100) / pricePerEvent);
  147. const oxfordCategories = listDisplayNames({
  148. plan: subscription.planDetails,
  149. categories: subscription.planDetails.categories,
  150. });
  151. return (
  152. <OnDemandField
  153. label={this.renderLabel()}
  154. help={
  155. <OnDemandAmount>
  156. {hasPerformance(subscription.planDetails)
  157. ? t('Applies to %s.', oxfordCategories)
  158. : tct('Up to [eventsLabel] errors monthly at [eventPrice] per error.', {
  159. eventsLabel: events?.toLocaleString(),
  160. eventPrice: <OnDemandPrice pricePerEvent={pricePerEvent} />,
  161. })}
  162. </OnDemandAmount>
  163. }
  164. >
  165. <Currency>
  166. <OnDemandInput
  167. name="onDemandMaxSpend"
  168. type="text"
  169. inputMode="numeric"
  170. pattern="[0-9]*"
  171. maxLength={7}
  172. placeholder="e.g. 50"
  173. value={value}
  174. onChange={this.parseValue}
  175. />
  176. </Currency>
  177. </OnDemandField>
  178. );
  179. }
  180. renderBody() {
  181. const {
  182. isCheckoutStep,
  183. hasPaymentSource,
  184. enabled,
  185. error,
  186. withHeader,
  187. showSave,
  188. subscription,
  189. } = this.props;
  190. const {initialValue, value} = this.state;
  191. if (!enabled) {
  192. return this.renderNotEnabled();
  193. }
  194. if (!hasPaymentSource && !subscription.onDemandInvoicedManual) {
  195. return this.renderNeedsPaymentSource();
  196. }
  197. return (
  198. <form className={enabled ? '' : 'disabled'} onSubmit={this.onSave}>
  199. {withHeader && <PanelHeader>{t('On-Demand Max Spend')}</PanelHeader>}
  200. {/* TODO(TS): Type says error might be an object */}
  201. {error && <PanelAlert type="error">{error as React.ReactNode}</PanelAlert>}
  202. <StyledPanelBody isCheckoutStep={isCheckoutStep}>
  203. {this.renderOnDemandInput()}
  204. </StyledPanelBody>
  205. {showSave && (
  206. <StyledPanelFooter>
  207. <Button
  208. priority="primary"
  209. onClick={this.onSave}
  210. disabled={initialValue === value}
  211. >
  212. {t('Save Changes')}
  213. </Button>
  214. </StyledPanelFooter>
  215. )}
  216. </form>
  217. );
  218. }
  219. render() {
  220. if (this.props.withPanel) {
  221. return <Panel>{this.renderBody()}</Panel>;
  222. }
  223. return this.renderBody();
  224. }
  225. }
  226. const StyledPanelBody = styled(PanelBody)<{isCheckoutStep?: boolean}>`
  227. padding: ${p => (p.isCheckoutStep ? space(3) : space(2))};
  228. padding-right: 0px;
  229. `;
  230. const StyledPanelFooter = styled(PanelFooter)`
  231. padding: ${space(1)} ${space(2)};
  232. text-align: right;
  233. `;
  234. const Label = styled('div')`
  235. display: inline-grid;
  236. grid-auto-flow: column;
  237. gap: ${space(1)};
  238. align-items: center;
  239. `;
  240. const OnDemandField = styled(FieldGroup)`
  241. padding: 0;
  242. `;
  243. const Currency = styled('span')`
  244. &::before {
  245. padding: 10px 10px 9px;
  246. position: absolute;
  247. content: '$';
  248. color: ${p => p.theme.textColor};
  249. font-size: ${p => p.theme.fontSizeLarge};
  250. }
  251. `;
  252. const OnDemandInput = styled(Input)`
  253. padding-left: ${space(4)};
  254. color: ${p => p.theme.textColor};
  255. max-width: 140px;
  256. height: 36px;
  257. `;
  258. const OnDemandAmount = styled('div')`
  259. display: grid;
  260. grid-auto-rows: auto;
  261. gap: ${space(0.5)};
  262. `;
  263. export default withOrganization(OnDemandSummary);