promotionModal.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. import type {ComponentType} from 'react';
  2. import {Fragment} from 'react';
  3. import {css} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import * as Sentry from '@sentry/react';
  6. import type {ModalRenderProps} from 'sentry/actionCreators/modal';
  7. import ButtonBar from 'sentry/components/buttonBar';
  8. import {Button} from 'sentry/components/core/button';
  9. import HighlightModalContainer from 'sentry/components/highlightModalContainer';
  10. import {IconArrow} from 'sentry/icons';
  11. import {t} from 'sentry/locale';
  12. import {space} from 'sentry/styles/space';
  13. import type {Organization} from 'sentry/types/organization';
  14. import useApi from 'sentry/utils/useApi';
  15. import {OrganizationContext} from 'sentry/views/organizationContext';
  16. import type {Promotion, PromotionData} from 'getsentry/types';
  17. import {claimAvailablePromotion} from 'getsentry/utils/promotionUtils';
  18. import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics';
  19. import withPromotions from 'getsentry/utils/withPromotions';
  20. import PromotionPriceDisplay from './promotionPriceDisplay';
  21. interface PromotionModalProps extends Pick<ModalRenderProps, 'closeModal'> {
  22. organization: Organization;
  23. price: number;
  24. promotion: Promotion;
  25. promotionData: PromotionData;
  26. promptFeature: string;
  27. PromotionModalBody?: ComponentType<PromotionModalBodyProps>;
  28. acceptButtonText?: string;
  29. declineButtonText?: string;
  30. onAccept?: () => void;
  31. }
  32. type PriceProps = {
  33. maxDiscount: number;
  34. percentOff: number;
  35. price: number;
  36. promoPrice?: boolean;
  37. };
  38. function calculatePrice({
  39. price,
  40. percentOff,
  41. maxDiscount,
  42. promoPrice = false,
  43. }: PriceProps) {
  44. const discount = promoPrice ? Math.min(price * (percentOff / 100), maxDiscount) : 0;
  45. return (price - discount) / 100;
  46. }
  47. export type PromotionModalBodyProps = {
  48. promotion: Promotion;
  49. };
  50. type DefaultDiscountBodyProps = PromotionModalBodyProps & {
  51. price: number;
  52. };
  53. function DefaultDiscountBody({promotion, price}: DefaultDiscountBodyProps) {
  54. const {amount, billingInterval, billingPeriods, maxCentsPerPeriod} =
  55. promotion.discountInfo;
  56. const interval = billingInterval === 'monthly' ? 'months' : 'years';
  57. const intervalSingular = interval.slice(0, -1);
  58. const percentOff = amount / 100;
  59. return (
  60. <Fragment>
  61. <h4>
  62. Get {percentOff}% off for the next {billingPeriods} {interval}*
  63. </h4>
  64. <p>
  65. Receive a {percentOff}% discount for the next {billingPeriods} {interval} for your
  66. total {billingInterval} bill up to ${maxCentsPerPeriod / 100} per{' '}
  67. {intervalSingular}.
  68. </p>
  69. <PromotionPriceComparison>
  70. <PromotionPriceDisplay
  71. price={calculatePrice({
  72. price,
  73. percentOff,
  74. maxDiscount: maxCentsPerPeriod,
  75. })}
  76. title="Current Price"
  77. />
  78. <IconArrow direction="right" size="lg" />
  79. <PromotionPriceDisplay
  80. price={calculatePrice({
  81. price,
  82. percentOff,
  83. maxDiscount: maxCentsPerPeriod,
  84. promoPrice: true,
  85. })}
  86. title="Promo Price"
  87. promo
  88. />
  89. </PromotionPriceComparison>
  90. </Fragment>
  91. );
  92. }
  93. const PromotionModal = withPromotions(
  94. (
  95. props: PromotionModalProps & {
  96. promotionData: PromotionData;
  97. }
  98. ) => {
  99. const api = useApi();
  100. const {
  101. price,
  102. promotionData,
  103. promotion,
  104. organization,
  105. acceptButtonText,
  106. declineButtonText,
  107. closeModal,
  108. onAccept,
  109. PromotionModalBody,
  110. promptFeature,
  111. } = props;
  112. const {name} = promotion;
  113. const {modalDisclaimerText} = promotion.discountInfo;
  114. const acceptText = acceptButtonText ?? t("Let's do it");
  115. const declineText = declineButtonText ?? t('Nah, I hate savings');
  116. const modalBody = PromotionModalBody ? (
  117. <PromotionModalBody promotion={promotion} />
  118. ) : (
  119. <DefaultDiscountBody promotion={promotion} price={price} />
  120. );
  121. async function handleClick() {
  122. closeModal();
  123. await claimAvailablePromotion({
  124. promotionData,
  125. organization,
  126. promptFeature,
  127. });
  128. onAccept?.();
  129. trackGetsentryAnalytics('growth.promo_modal_accept', {
  130. organization,
  131. promo: name,
  132. });
  133. }
  134. /**
  135. * Removed translation because of complicated pluralization and lots of changing
  136. * parameters from the different promotions we can use this for
  137. */
  138. return (
  139. <HighlightModalContainer topWidth="200px" bottomWidth="150px">
  140. <Subheader>{t('Limited Time Offer')}</Subheader>
  141. {modalBody}
  142. <StyledButtonBar gap={1}>
  143. <Button size="md" priority="primary" onClick={() => handleClick()}>
  144. {acceptText}
  145. </Button>
  146. <Button
  147. size="md"
  148. onClick={async () => {
  149. closeModal();
  150. trackGetsentryAnalytics('growth.promo_modal_decline', {
  151. organization,
  152. promo: name,
  153. });
  154. try {
  155. await api.requestPromise(
  156. `/organizations/${organization.slug}/promotions/${promotion.slug}/decline/`,
  157. {
  158. method: 'POST',
  159. }
  160. );
  161. } catch (err) {
  162. Sentry.captureException(err);
  163. }
  164. }}
  165. >
  166. {declineText}
  167. </Button>
  168. </StyledButtonBar>
  169. <DisclaimerText>{modalDisclaimerText}</DisclaimerText>
  170. </HighlightModalContainer>
  171. );
  172. }
  173. );
  174. function PromotionModalWrapper(props: Omit<PromotionModalProps, 'promotionData'>) {
  175. // provide org context so we can use withPromotions
  176. return (
  177. <OrganizationContext.Provider value={props.organization}>
  178. <PromotionModal {...props} />
  179. </OrganizationContext.Provider>
  180. );
  181. }
  182. export default PromotionModalWrapper;
  183. const Subheader = styled('div')`
  184. text-transform: uppercase;
  185. font-weight: bold;
  186. color: ${p => p.theme.purple300};
  187. font-size: ${p => p.theme.fontSizeMedium};
  188. margin-bottom: ${space(1)};
  189. `;
  190. const DisclaimerText = styled('div')`
  191. font-size: ${p => p.theme.fontSizeSmall};
  192. color: ${p => p.theme.gray400};
  193. margin-top: ${space(1)};
  194. `;
  195. const PromotionPriceComparison = styled('div')`
  196. display: flex;
  197. gap: ${space(2)};
  198. align-items: center;
  199. `;
  200. const StyledButtonBar = styled(ButtonBar)`
  201. max-width: 150px;
  202. margin-top: ${space(2)};
  203. `;
  204. export const modalCss = css`
  205. width: 100%;
  206. max-width: 500px;
  207. [role='document'] {
  208. position: relative;
  209. padding: 50px 50px;
  210. }
  211. `;