123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271 |
- import {Fragment, useEffect, useState} from 'react';
- import styled from '@emotion/styled';
- import moment from 'moment-timezone';
- import {addSuccessMessage} from 'sentry/actionCreators/indicator';
- import {Button} from 'sentry/components/button';
- import {Alert} from 'sentry/components/core/alert';
- import RadioGroupField from 'sentry/components/forms/fields/radioField';
- import TextareaField from 'sentry/components/forms/fields/textareaField';
- import Form from 'sentry/components/forms/form';
- import ExternalLink from 'sentry/components/links/externalLink';
- import LoadingIndicator from 'sentry/components/loadingIndicator';
- import Panel from 'sentry/components/panels/panel';
- import PanelBody from 'sentry/components/panels/panelBody';
- import PanelHeader from 'sentry/components/panels/panelHeader';
- import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
- import {t, tct} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import {browserHistory} from 'sentry/utils/browserHistory';
- import {useApiQuery} from 'sentry/utils/queryClient';
- import normalizeUrl from 'sentry/utils/url/normalizeUrl';
- import useApi from 'sentry/utils/useApi';
- import useOrganization from 'sentry/utils/useOrganization';
- import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
- import TextBlock from 'sentry/views/settings/components/text/textBlock';
- import withSubscription from 'getsentry/components/withSubscription';
- import {ANNUAL} from 'getsentry/constants';
- import subscriptionStore from 'getsentry/stores/subscriptionStore';
- import type {PromotionData, Subscription} from 'getsentry/types';
- import {checkForPromptBasedPromotion} from 'getsentry/utils/promotionUtils';
- import usePromotionTriggerCheck from 'getsentry/utils/usePromotionTriggerCheck';
- import withPromotions from 'getsentry/utils/withPromotions';
- type CancelReason = [string, React.ReactNode];
- const CANCEL_STEPS: Array<{followup: React.ReactNode; reason: CancelReason}> = [
- {
- reason: ['shutting_down', t('The project/product/company is shutting down.')],
- followup: t('Sorry to hear that! Anything more we should know?'),
- },
- {
- reason: ['only_need_free', t('We only need the features on the free plan.')],
- followup: t(
- 'Fair enough. Which features on the free plan are most important to you?'
- ),
- },
- {
- reason: ['not_a_fit', t("Sentry doesn't fit our needs.")],
- followup: t('Bummer. What features were missing for you?'),
- },
- {
- reason: ['competitor', t('We are switching to a different solution.')],
- followup: t('Thanks for letting us know. Which solution(s)? Why?'),
- },
- {
- reason: ['pricing', t("The pricing doesn't fit our needs.")],
- followup: t("What about it wasn't right for you?"),
- },
- {
- reason: ['self_hosted', t('We are hosting Sentry ourselves.')],
- followup: t('Are you interested in a single tenant version of Sentry?'),
- },
- {
- reason: ['no_more_errors', t('We no longer get any errors.')],
- followup: t("Congrats! What's your secret?"),
- },
- {
- reason: ['other', t('Other')],
- followup: t('Other reason?'),
- },
- ];
- type State = {
- canSubmit: boolean;
- showFollowup: boolean;
- understandsMembers: boolean;
- val: CancelReason[0] | null;
- };
- function CancelSubscriptionForm() {
- const organization = useOrganization();
- const {data: subscription, isPending} = useApiQuery<Subscription>(
- [`/customers/${organization.slug}/`],
- {staleTime: 0}
- );
- const [state, setState] = useState<State>({
- canSubmit: false,
- showFollowup: false,
- understandsMembers: false,
- val: null,
- });
- if (isPending || !subscription) {
- return <LoadingIndicator />;
- }
- const canCancelPlan = subscription.canSelfServe && subscription.canCancel;
- if (!canCancelPlan) {
- return (
- <Alert.Container>
- <Alert type="error" showIcon>
- {t('Your plan is not eligible to be cancelled.')}
- </Alert>
- </Alert.Container>
- );
- }
- if (subscription.usedLicenses > 1 && !state.understandsMembers) {
- return (
- <Fragment>
- <Alert.Container>
- <Alert type="error" showIcon>
- {tct(
- `Upon cancellation your account will be downgraded to a free plan which is limited to a single user.
- Your account currently has [count] [teamMembers: other team member(s)] using Sentry that would lose
- access upon cancelling your subscription.`,
- {
- count: <strong>{subscription.usedLicenses - 1}</strong>,
- teamMembers: <strong />,
- }
- )}
- </Alert>
- </Alert.Container>
- <Button
- priority="danger"
- onClick={() =>
- setState(currentState => ({...currentState, understandsMembers: true}))
- }
- >
- {t('I understand')}
- </Button>
- </Fragment>
- );
- }
- const followup = CANCEL_STEPS.find(cancel => cancel.reason[0] === state.val)?.followup;
- const handleSubmitSuccess = (resp: any) => {
- subscriptionStore.loadData(organization.slug);
- const msg = resp?.responseJSON?.details || t('Successfully cancelled subscription');
- addSuccessMessage(msg);
- browserHistory.push(normalizeUrl(`/settings/${organization.slug}/billing/`));
- };
- return (
- <Fragment>
- <Alert.Container>
- <Alert type="warning" showIcon>
- {tct(
- `Your organization is currently subscribed to the [planName] plan on a [interval] contract.
- Cancelling your subscription will downgrade your account to a free plan at the end
- of your contract on [contractEndDate]. See [changesLink:upcoming changes] to our free Developer plan.`,
- {
- interval: subscription?.contractInterval === ANNUAL ? 'annual' : 'monthly',
- planName: <strong>{subscription?.planDetails?.name}</strong>,
- contractEndDate: (
- <strong>{moment(subscription.contractPeriodEnd).format('ll')}</strong>
- ),
- changesLink: (
- <ExternalLink href="https://sentry.zendesk.com/hc/en-us/articles/26206897429275-Changes-to-our-Developer-plan" />
- ),
- }
- )}
- </Alert>
- </Alert.Container>
- <Panel>
- <PanelHeader>{t('Cancellation Reason')}</PanelHeader>
- <PanelBody withPadding>
- <Form
- apiMethod="DELETE"
- apiEndpoint={`/customers/${subscription.slug}/`}
- onSubmitSuccess={handleSubmitSuccess}
- hideFooter
- >
- <TextBlock>
- {t('Please help us understand why you are cancelling:')}
- </TextBlock>
- <RadioGroupField
- stacked
- name="reason"
- label=""
- inline={false}
- choices={CANCEL_STEPS.map<CancelReason>(cancel => cancel.reason)}
- onChange={(val: any) =>
- setState(currentState => ({
- ...currentState,
- canSubmit: true,
- showFollowup: true,
- val,
- }))
- }
- />
- {state.showFollowup && (
- <TextareaField stacked label={followup} name="followup" inline={false} />
- )}
- <ButtonList>
- <Button type="submit" priority="danger" disabled={!state.canSubmit}>
- {t('Cancel Subscription')}
- </Button>
- <Button
- onClick={() => {
- browserHistory.push(
- normalizeUrl(`/settings/${organization.slug}/billing/`)
- );
- }}
- >
- {t('Never Mind')}
- </Button>
- </ButtonList>
- </Form>
- </PanelBody>
- </Panel>
- </Fragment>
- );
- }
- const ButtonList = styled('div')`
- display: inline-grid;
- grid-auto-flow: column;
- gap: ${space(1)};
- margin-top: ${space(1)};
- `;
- interface CancelSubscriptionWrapperProps {
- subscription: Subscription;
- promotionData?: PromotionData;
- }
- function CancelSubscriptionWrapper({
- promotionData,
- subscription,
- }: CancelSubscriptionWrapperProps) {
- const api = useApi();
- const organization = useOrganization();
- const {refetch} = usePromotionTriggerCheck(organization);
- const switchToBillingOverview = () =>
- browserHistory.push('/settings/billing/overview/');
- useEffect(() => {
- // when we mount, we know someone is thinking about canceling their subscription
- if (promotionData) {
- checkForPromptBasedPromotion({
- organization,
- refetch,
- promptFeature: 'cancel_subscription',
- subscription,
- promotionData,
- onAcceptConditions: switchToBillingOverview,
- });
- }
- }, [api, organization, refetch, subscription, promotionData]);
- const title = t('Cancel Subscription');
- return (
- <div data-test-id="cancel-subscription">
- <SentryDocumentTitle title={title} />
- <SettingsPageHeader title={title} />
- <CancelSubscriptionForm />
- </div>
- );
- }
- export default withSubscription(withPromotions(CancelSubscriptionWrapper), {
- noLoader: true,
- });
|