cancelSubscription.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. import {Fragment, useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import moment from 'moment-timezone';
  4. import {addSuccessMessage} from 'sentry/actionCreators/indicator';
  5. import {Button} from 'sentry/components/button';
  6. import {Alert} from 'sentry/components/core/alert';
  7. import RadioGroupField from 'sentry/components/forms/fields/radioField';
  8. import TextareaField from 'sentry/components/forms/fields/textareaField';
  9. import Form from 'sentry/components/forms/form';
  10. import ExternalLink from 'sentry/components/links/externalLink';
  11. import LoadingIndicator from 'sentry/components/loadingIndicator';
  12. import Panel from 'sentry/components/panels/panel';
  13. import PanelBody from 'sentry/components/panels/panelBody';
  14. import PanelHeader from 'sentry/components/panels/panelHeader';
  15. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  16. import {t, tct} from 'sentry/locale';
  17. import {space} from 'sentry/styles/space';
  18. import {browserHistory} from 'sentry/utils/browserHistory';
  19. import {useApiQuery} from 'sentry/utils/queryClient';
  20. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  21. import useApi from 'sentry/utils/useApi';
  22. import useOrganization from 'sentry/utils/useOrganization';
  23. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  24. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  25. import withSubscription from 'getsentry/components/withSubscription';
  26. import {ANNUAL} from 'getsentry/constants';
  27. import subscriptionStore from 'getsentry/stores/subscriptionStore';
  28. import type {PromotionData, Subscription} from 'getsentry/types';
  29. import {checkForPromptBasedPromotion} from 'getsentry/utils/promotionUtils';
  30. import usePromotionTriggerCheck from 'getsentry/utils/usePromotionTriggerCheck';
  31. import withPromotions from 'getsentry/utils/withPromotions';
  32. type CancelReason = [string, React.ReactNode];
  33. const CANCEL_STEPS: Array<{followup: React.ReactNode; reason: CancelReason}> = [
  34. {
  35. reason: ['shutting_down', t('The project/product/company is shutting down.')],
  36. followup: t('Sorry to hear that! Anything more we should know?'),
  37. },
  38. {
  39. reason: ['only_need_free', t('We only need the features on the free plan.')],
  40. followup: t(
  41. 'Fair enough. Which features on the free plan are most important to you?'
  42. ),
  43. },
  44. {
  45. reason: ['not_a_fit', t("Sentry doesn't fit our needs.")],
  46. followup: t('Bummer. What features were missing for you?'),
  47. },
  48. {
  49. reason: ['competitor', t('We are switching to a different solution.')],
  50. followup: t('Thanks for letting us know. Which solution(s)? Why?'),
  51. },
  52. {
  53. reason: ['pricing', t("The pricing doesn't fit our needs.")],
  54. followup: t("What about it wasn't right for you?"),
  55. },
  56. {
  57. reason: ['self_hosted', t('We are hosting Sentry ourselves.')],
  58. followup: t('Are you interested in a single tenant version of Sentry?'),
  59. },
  60. {
  61. reason: ['no_more_errors', t('We no longer get any errors.')],
  62. followup: t("Congrats! What's your secret?"),
  63. },
  64. {
  65. reason: ['other', t('Other')],
  66. followup: t('Other reason?'),
  67. },
  68. ];
  69. type State = {
  70. canSubmit: boolean;
  71. showFollowup: boolean;
  72. understandsMembers: boolean;
  73. val: CancelReason[0] | null;
  74. };
  75. function CancelSubscriptionForm() {
  76. const organization = useOrganization();
  77. const {data: subscription, isPending} = useApiQuery<Subscription>(
  78. [`/customers/${organization.slug}/`],
  79. {staleTime: 0}
  80. );
  81. const [state, setState] = useState<State>({
  82. canSubmit: false,
  83. showFollowup: false,
  84. understandsMembers: false,
  85. val: null,
  86. });
  87. if (isPending || !subscription) {
  88. return <LoadingIndicator />;
  89. }
  90. const canCancelPlan = subscription.canSelfServe && subscription.canCancel;
  91. if (!canCancelPlan) {
  92. return (
  93. <Alert.Container>
  94. <Alert type="error" showIcon>
  95. {t('Your plan is not eligible to be cancelled.')}
  96. </Alert>
  97. </Alert.Container>
  98. );
  99. }
  100. if (subscription.usedLicenses > 1 && !state.understandsMembers) {
  101. return (
  102. <Fragment>
  103. <Alert.Container>
  104. <Alert type="error" showIcon>
  105. {tct(
  106. `Upon cancellation your account will be downgraded to a free plan which is limited to a single user.
  107. Your account currently has [count] [teamMembers: other team member(s)] using Sentry that would lose
  108. access upon cancelling your subscription.`,
  109. {
  110. count: <strong>{subscription.usedLicenses - 1}</strong>,
  111. teamMembers: <strong />,
  112. }
  113. )}
  114. </Alert>
  115. </Alert.Container>
  116. <Button
  117. priority="danger"
  118. onClick={() =>
  119. setState(currentState => ({...currentState, understandsMembers: true}))
  120. }
  121. >
  122. {t('I understand')}
  123. </Button>
  124. </Fragment>
  125. );
  126. }
  127. const followup = CANCEL_STEPS.find(cancel => cancel.reason[0] === state.val)?.followup;
  128. const handleSubmitSuccess = (resp: any) => {
  129. subscriptionStore.loadData(organization.slug);
  130. const msg = resp?.responseJSON?.details || t('Successfully cancelled subscription');
  131. addSuccessMessage(msg);
  132. browserHistory.push(normalizeUrl(`/settings/${organization.slug}/billing/`));
  133. };
  134. return (
  135. <Fragment>
  136. <Alert.Container>
  137. <Alert type="warning" showIcon>
  138. {tct(
  139. `Your organization is currently subscribed to the [planName] plan on a [interval] contract.
  140. Cancelling your subscription will downgrade your account to a free plan at the end
  141. of your contract on [contractEndDate]. See [changesLink:upcoming changes] to our free Developer plan.`,
  142. {
  143. interval: subscription?.contractInterval === ANNUAL ? 'annual' : 'monthly',
  144. planName: <strong>{subscription?.planDetails?.name}</strong>,
  145. contractEndDate: (
  146. <strong>{moment(subscription.contractPeriodEnd).format('ll')}</strong>
  147. ),
  148. changesLink: (
  149. <ExternalLink href="https://sentry.zendesk.com/hc/en-us/articles/26206897429275-Changes-to-our-Developer-plan" />
  150. ),
  151. }
  152. )}
  153. </Alert>
  154. </Alert.Container>
  155. <Panel>
  156. <PanelHeader>{t('Cancellation Reason')}</PanelHeader>
  157. <PanelBody withPadding>
  158. <Form
  159. apiMethod="DELETE"
  160. apiEndpoint={`/customers/${subscription.slug}/`}
  161. onSubmitSuccess={handleSubmitSuccess}
  162. hideFooter
  163. >
  164. <TextBlock>
  165. {t('Please help us understand why you are cancelling:')}
  166. </TextBlock>
  167. <RadioGroupField
  168. stacked
  169. name="reason"
  170. label=""
  171. inline={false}
  172. choices={CANCEL_STEPS.map<CancelReason>(cancel => cancel.reason)}
  173. onChange={(val: any) =>
  174. setState(currentState => ({
  175. ...currentState,
  176. canSubmit: true,
  177. showFollowup: true,
  178. val,
  179. }))
  180. }
  181. />
  182. {state.showFollowup && (
  183. <TextareaField stacked label={followup} name="followup" inline={false} />
  184. )}
  185. <ButtonList>
  186. <Button type="submit" priority="danger" disabled={!state.canSubmit}>
  187. {t('Cancel Subscription')}
  188. </Button>
  189. <Button
  190. onClick={() => {
  191. browserHistory.push(
  192. normalizeUrl(`/settings/${organization.slug}/billing/`)
  193. );
  194. }}
  195. >
  196. {t('Never Mind')}
  197. </Button>
  198. </ButtonList>
  199. </Form>
  200. </PanelBody>
  201. </Panel>
  202. </Fragment>
  203. );
  204. }
  205. const ButtonList = styled('div')`
  206. display: inline-grid;
  207. grid-auto-flow: column;
  208. gap: ${space(1)};
  209. margin-top: ${space(1)};
  210. `;
  211. interface CancelSubscriptionWrapperProps {
  212. subscription: Subscription;
  213. promotionData?: PromotionData;
  214. }
  215. function CancelSubscriptionWrapper({
  216. promotionData,
  217. subscription,
  218. }: CancelSubscriptionWrapperProps) {
  219. const api = useApi();
  220. const organization = useOrganization();
  221. const {refetch} = usePromotionTriggerCheck(organization);
  222. const switchToBillingOverview = () =>
  223. browserHistory.push('/settings/billing/overview/');
  224. useEffect(() => {
  225. // when we mount, we know someone is thinking about canceling their subscription
  226. if (promotionData) {
  227. checkForPromptBasedPromotion({
  228. organization,
  229. refetch,
  230. promptFeature: 'cancel_subscription',
  231. subscription,
  232. promotionData,
  233. onAcceptConditions: switchToBillingOverview,
  234. });
  235. }
  236. }, [api, organization, refetch, subscription, promotionData]);
  237. const title = t('Cancel Subscription');
  238. return (
  239. <div data-test-id="cancel-subscription">
  240. <SentryDocumentTitle title={title} />
  241. <SettingsPageHeader title={title} />
  242. <CancelSubscriptionForm />
  243. </div>
  244. );
  245. }
  246. export default withSubscription(withPromotions(CancelSubscriptionWrapper), {
  247. noLoader: true,
  248. });