Quellcode durchsuchen

Merge pull request #11981 from overleaf/jel-downgrade-when-cancel

[web] Migrate downgrade plan option when cancelling in React subscription dash

GitOrigin-RevId: 11a4b39deb58493f3f56e65e8760d71527a8e8fc
Jessica Lawshe vor 3 Jahren
Ursprung
Commit
7469f01acd

+ 2 - 0
services/web/frontend/extracted-translations.json

@@ -387,6 +387,7 @@
   "institution_and_role": "",
   "institutional_leavers_survey_notification": "",
   "integrations": "",
+  "interested_in_cheaper_personal_plan": "",
   "invalid_email": "",
   "invalid_file_name": "",
   "invalid_filename": "",
@@ -916,6 +917,7 @@
   "x_price_per_user": "",
   "x_price_per_year": "",
   "year": "",
+  "yes_move_me_to_personal_plan": "",
   "you_already_have_a_subscription": "",
   "you_are_a_manager_and_member_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
   "you_are_a_manager_of_commons_at_institution_x": "",

+ 52 - 9
services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx

@@ -1,5 +1,7 @@
 import { Trans, useTranslation } from 'react-i18next'
+import { Plan } from '../../../../../../../../../types/subscription/plan'
 import { postJSON } from '../../../../../../../infrastructure/fetch-json'
+import LoadingSpinner from '../../../../../../../shared/components/loading-spinner'
 import useAsync from '../../../../../../../shared/hooks/use-async'
 import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
 import {
@@ -10,8 +12,11 @@ import canExtendTrial from '../../../../../util/can-extend-trial'
 import showDowngradeOption from '../../../../../util/show-downgrade-option'
 import ActionButtonText from '../../../action-button-text'
 import GenericErrorAlert from '../../../generic-error-alert'
+import DowngradePlanButton from './downgrade-plan-button'
 import ExtendTrialButton from './extend-trial-button'
 
+const planCodeToDowngradeTo = 'paid-personal'
+
 function ConfirmCancelSubscriptionButton({
   buttonClass,
   buttonText,
@@ -45,13 +50,17 @@ function NotCancelOption({
   isButtonDisabled,
   isLoadingSecondaryAction,
   isSuccessSecondaryAction,
+  planToDowngradeTo,
   showExtendFreeTrial,
+  showDowngrade,
   runAsyncSecondaryAction,
 }: {
   isButtonDisabled: boolean
   isLoadingSecondaryAction: boolean
   isSuccessSecondaryAction: boolean
+  planToDowngradeTo?: Plan
   showExtendFreeTrial: boolean
+  showDowngrade: boolean
   runAsyncSecondaryAction: (promise: Promise<unknown>) => Promise<unknown>
 }) {
   const { t } = useTranslation()
@@ -85,6 +94,34 @@ function NotCancelOption({
     )
   }
 
+  if (showDowngrade && planToDowngradeTo) {
+    return (
+      <>
+        <p>
+          <Trans
+            i18nKey="interested_in_cheaper_personal_plan"
+            values={{
+              price: planToDowngradeTo.displayPrice,
+            }}
+            components={[
+              // eslint-disable-next-line react/jsx-key
+              <strong />,
+            ]}
+          />
+        </p>
+        <p>
+          <DowngradePlanButton
+            isButtonDisabled={isButtonDisabled}
+            isLoadingSecondaryAction={isLoadingSecondaryAction}
+            isSuccessSecondaryAction={isSuccessSecondaryAction}
+            planToDowngradeTo={planToDowngradeTo}
+            runAsyncSecondaryAction={runAsyncSecondaryAction}
+          />
+        </p>
+      </>
+    )
+  }
+
   function handleKeepPlan() {
     setShowCancellation(false)
   }
@@ -103,8 +140,7 @@ function NotCancelOption({
 
 export function CancelSubscription() {
   const { t } = useTranslation()
-  const { personalSubscription } = useSubscriptionDashboardContext()
-
+  const { personalSubscription, plans } = useSubscriptionDashboardContext()
   const {
     isLoading: isLoadingCancel,
     isError: isErrorCancel,
@@ -117,7 +153,6 @@ export function CancelSubscription() {
     isSuccess: isSuccessSecondaryAction,
     runAsync: runAsyncSecondaryAction,
   } = useAsync()
-
   const isButtonDisabled =
     isLoadingCancel ||
     isLoadingSecondaryAction ||
@@ -126,6 +161,18 @@ export function CancelSubscription() {
 
   if (!personalSubscription || !('recurly' in personalSubscription)) return null
 
+  const showDowngrade = showDowngradeOption(
+    personalSubscription.plan.planCode,
+    personalSubscription.plan.groupPlan,
+    personalSubscription.recurly.trial_ends_at
+  )
+  const planToDowngradeTo = plans.find(
+    plan => plan.planCode === planCodeToDowngradeTo
+  )
+  if (showDowngrade && !planToDowngradeTo) {
+    return <LoadingSpinner />
+  }
+
   async function handleCancelSubscription() {
     try {
       await runAsyncCancel(postJSON(cancelSubscriptionUrl))
@@ -141,12 +188,6 @@ export function CancelSubscription() {
     personalSubscription.recurly.trial_ends_at
   )
 
-  const showDowngrade = showDowngradeOption(
-    personalSubscription.plan.planCode,
-    personalSubscription.plan.groupPlan,
-    personalSubscription.recurly.trial_ends_at
-  )
-
   let confirmCancelButtonText = t('cancel_my_account')
   let confirmCancelButtonClass = 'btn-primary'
   if (showExtendFreeTrial || showDowngrade) {
@@ -164,9 +205,11 @@ export function CancelSubscription() {
 
       <NotCancelOption
         showExtendFreeTrial={showExtendFreeTrial}
+        showDowngrade={showDowngrade}
         isButtonDisabled={isButtonDisabled}
         isLoadingSecondaryAction={isLoadingSecondaryAction}
         isSuccessSecondaryAction={isSuccessSecondaryAction}
+        planToDowngradeTo={planToDowngradeTo}
         runAsyncSecondaryAction={runAsyncSecondaryAction}
       />
 

+ 50 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/downgrade-plan-button.tsx

@@ -0,0 +1,50 @@
+import { useTranslation } from 'react-i18next'
+import { Plan } from '../../../../../../../../../types/subscription/plan'
+import { postJSON } from '../../../../../../../infrastructure/fetch-json'
+import { subscriptionUpdateUrl } from '../../../../../data/subscription-url'
+import ActionButtonText from '../../../action-button-text'
+
+export default function DowngradePlanButton({
+  isButtonDisabled,
+  isLoadingSecondaryAction,
+  isSuccessSecondaryAction,
+  planToDowngradeTo,
+  runAsyncSecondaryAction,
+}: {
+  isButtonDisabled: boolean
+  isLoadingSecondaryAction: boolean
+  isSuccessSecondaryAction: boolean
+  planToDowngradeTo: Plan
+  runAsyncSecondaryAction: (promise: Promise<unknown>) => Promise<unknown>
+}) {
+  const { t } = useTranslation()
+  const buttonText = t('yes_move_me_to_personal_plan')
+
+  async function handleDowngradePlan() {
+    try {
+      await runAsyncSecondaryAction(
+        postJSON(`${subscriptionUpdateUrl}?downgradeToPaidPersonal`, {
+          body: { plan_code: planToDowngradeTo.planCode },
+        })
+      )
+      window.location.reload()
+    } catch (e) {
+      console.error(e)
+    }
+  }
+
+  return (
+    <>
+      <button
+        className="btn btn-primary"
+        onClick={handleDowngradePlan}
+        disabled={isButtonDisabled}
+      >
+        <ActionButtonText
+          inflight={isLoadingSecondaryAction || isSuccessSecondaryAction}
+          buttonText={buttonText}
+        />
+      </button>
+    </>
+  )
+}

+ 95 - 3
services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx

@@ -19,6 +19,7 @@ import fetchMock from 'fetch-mock'
 import {
   cancelSubscriptionUrl,
   extendTrialUrl,
+  subscriptionUpdateUrl,
 } from '../../../../../../../../frontend/js/features/subscription/data/subscription-url'
 
 describe('<ActiveSubscription />', function () {
@@ -380,7 +381,7 @@ describe('<ActiveSubscription />', function () {
         ).to.be.null
       })
 
-      it('reloads page after the succesful request to extend trial', async function () {
+      it('reloads page after the successful request to extend trial', async function () {
         const endPointResponse = {
           status: 200,
         }
@@ -399,11 +400,102 @@ describe('<ActiveSubscription />', function () {
     })
 
     describe('downgrade plan', function () {
-      it('shows alternate cancel subscription button text', function () {
+      const cancelButtonText = 'No thanks, I still want to cancel'
+      const downgradeButtonText = 'Yes, move me to the Personal plan'
+      it('shows alternate cancel subscription button text', async function () {
+        renderActiveSubscription(monthlyActiveCollaborator)
+        showConfirmCancelUI()
+        await screen.findByRole('button', {
+          name: cancelButtonText,
+        })
+        screen.getByRole('button', {
+          name: downgradeButtonText,
+        })
+        screen.getByText('Would you be interested in the cheaper', {
+          exact: false,
+        })
+        screen.getByText('Personal plan?', {
+          exact: false,
+        })
+      })
+
+      it('disables both buttons and updates text for when trial button clicked', async function () {
         renderActiveSubscription(monthlyActiveCollaborator)
         showConfirmCancelUI()
+        const downgradeButton = await screen.findByRole('button', {
+          name: downgradeButtonText,
+        })
+        fireEvent.click(downgradeButton)
+
+        const buttons = screen.getAllByRole('button')
+        expect(buttons.length).to.equal(2)
+        expect(buttons[0].getAttribute('disabled')).to.equal('')
+        expect(buttons[1].getAttribute('disabled')).to.equal('')
+        screen.getByRole('button', {
+          name: cancelButtonText,
+        })
+        screen.getByRole('button', {
+          name: 'Processing…',
+        })
+      })
+
+      it('disables both buttons and updates text for when cancel button clicked', async function () {
+        renderActiveSubscription(monthlyActiveCollaborator)
+        showConfirmCancelUI()
+        const cancelButtton = await screen.findByRole('button', {
+          name: cancelButtonText,
+        })
+        fireEvent.click(cancelButtton)
+
+        const buttons = screen.getAllByRole('button')
+        expect(buttons.length).to.equal(2)
+        expect(buttons[0].getAttribute('disabled')).to.equal('')
+        expect(buttons[1].getAttribute('disabled')).to.equal('')
+        screen.getByRole('button', {
+          name: 'Processing…',
+        })
         screen.getByRole('button', {
-          name: 'No thanks, I still want to cancel',
+          name: downgradeButtonText,
+        })
+      })
+
+      it('does not show option to downgrade when not a collaborator plan', function () {
+        const trialPlan = cloneDeep(monthlyActiveCollaborator)
+        trialPlan.plan.planCode = 'anotherplan'
+        renderActiveSubscription(trialPlan)
+        showConfirmCancelUI()
+        expect(
+          screen.queryByRole('button', {
+            name: downgradeButtonText,
+          })
+        ).to.be.null
+      })
+
+      it('does not show option to extend trial when on a collaborator trial', function () {
+        const trialPlan = cloneDeep(trialCollaboratorSubscription)
+        renderActiveSubscription(trialPlan)
+        showConfirmCancelUI()
+        expect(
+          screen.queryByRole('button', {
+            name: downgradeButtonText,
+          })
+        ).to.be.null
+      })
+
+      it('reloads page after the successful request to downgrade plan', async function () {
+        const endPointResponse = {
+          status: 200,
+        }
+        fetchMock.post(subscriptionUpdateUrl, endPointResponse)
+        renderActiveSubscription(monthlyActiveCollaborator)
+        showConfirmCancelUI()
+        const downgradeButton = await screen.findByRole('button', {
+          name: downgradeButtonText,
+        })
+        fireEvent.click(downgradeButton)
+        // page is reloaded on success
+        await waitFor(() => {
+          expect(reloadStub).to.have.been.called
         })
       })
     })