Просмотр исходного кода

Merge pull request #11414 from overleaf/jel-react-personal-subscription-dash-pt-2

[web] Continue migration of personal subscription dash commons to React

GitOrigin-RevId: 997716f70c953b088f686d4b0f638705ad838520
Jessica Lawshe 3 лет назад
Родитель
Сommit
dad1460d71
18 измененных файлов с 696 добавлено и 120 удалено
  1. 3 0
      services/web/app/views/subscriptions/dashboard-react.pug
  2. 7 0
      services/web/frontend/extracted-translations.json
  3. 18 2
      services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx
  4. 0 111
      services/web/frontend/js/features/subscription/components/dashboard/states/active.tsx
  5. 117 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx
  6. 56 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx
  7. 15 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription.tsx
  8. 18 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan.tsx
  9. 49 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/pending-plan-change.tsx
  10. 26 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/trial-ending.tsx
  11. 18 1
      services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx
  12. 1 0
      services/web/locales/en.json
  13. 37 1
      services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx
  14. 155 3
      services/web/test/frontend/features/subscription/components/dashboard/states/active.test.tsx
  15. 6 1
      services/web/test/frontend/features/subscription/components/dashboard/subscription-dashboard.test.tsx
  16. 165 0
      services/web/test/frontend/features/subscription/fixtures/subscriptions.tsx
  17. 4 1
      services/web/types/subscription/dashboard/subscription.ts
  18. 1 0
      services/web/types/window.ts

+ 3 - 0
services/web/app/views/subscriptions/dashboard-react.pug

@@ -3,6 +3,9 @@ extends ../layout-marketing
 block entrypointVar
 	- entrypoint = 'pages/user/subscription/dashboard'
 
+block head-scripts
+	script(type="text/javascript", nonce=scriptNonce, src="https://js.recurly.com/v4/recurly.js")
+
 block append meta
 	meta(name="ol-managedInstitutions", data-type="json", content=managedInstitutions)
 	meta(name="ol-planCodesChangingAtTermEnd", data-type="json", content=plans.planCodesChangingAtTermEnd)

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

@@ -22,6 +22,7 @@
   "add_or_remove_project_from_tag": "",
   "add_role_and_department": "",
   "add_to_folder": "",
+  "additional_licenses": "",
   "all_projects": "",
   "also": "",
   "anyone_with_link_can_edit": "",
@@ -57,6 +58,7 @@
   "can_link_your_institution_acct_2": "",
   "can_now_relink_dropbox": "",
   "cancel": "",
+  "cancel_your_subscription": "",
   "cannot_invite_non_user": "",
   "cannot_invite_self": "",
   "cannot_verify_user_not_robot": "",
@@ -475,6 +477,7 @@
   "password": "",
   "password_managed_externally": "",
   "password_was_detected_on_a_public_list_of_known_compromised_passwords": "",
+  "payment_provider_unreachable_error": "",
   "pdf_compile_in_progress_error": "",
   "pdf_compile_rate_limit_hit": "",
   "pdf_compile_try_again": "",
@@ -484,6 +487,7 @@
   "pdf_rendering_error": "",
   "pdf_viewer": "",
   "pdf_viewer_error": "",
+  "pending_additional_licenses": "",
   "plan_tooltip": "",
   "please_change_primary_to_remove": "",
   "please_check_your_inbox": "",
@@ -663,6 +667,7 @@
   "submit_title": "",
   "subscription_admins_cannot_be_deleted": "",
   "subscription_canceled_and_terminate_on_x": "",
+  "subscription_will_remain_active_until_end_of_billing_period_x": "",
   "sure_you_want_to_delete": "",
   "switch_to_editor": "",
   "switch_to_pdf": "",
@@ -763,6 +768,7 @@
   "want_change_to_apply_before_plan_end": "",
   "we_cant_find_any_sections_or_subsections_in_this_file": "",
   "we_logged_you_in": "",
+  "wed_love_you_to_stay": "",
   "welcome_to_sl": "",
   "wide": "",
   "with_premium_subscription_you_also_get": "",
@@ -779,6 +785,7 @@
   "your_projects": "",
   "your_subscription": "",
   "your_subscription_has_expired": "",
+  "youre_on_free_trial_which_ends_on": "",
   "zotero_groups_loading_error": "",
   "zotero_groups_relink": "",
   "zotero_integration": "",

+ 18 - 2
services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx

@@ -1,8 +1,10 @@
+import { useEffect } from 'react'
 import { useTranslation } from 'react-i18next'
 import { Subscription } from '../../../../../../types/subscription/dashboard/subscription'
-import { ActiveSubsciption } from './states/active'
+import { ActiveSubsciption } from './states/active/active'
 import { CanceledSubsciption } from './states/canceled'
 import { ExpiredSubsciption } from './states/expired'
+import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
 
 function PastDueSubscriptionAlert({
   subscription,
@@ -51,9 +53,18 @@ function PersonalSubscription({
 }: {
   subscription?: Subscription
 }) {
+  const { t } = useTranslation()
+  const { recurlyLoadError, setRecurlyLoadError } =
+    useSubscriptionDashboardContext()
   const state = subscription?.recurly?.state
 
-  if (!subscription) return <></>
+  useEffect(() => {
+    if (typeof window.recurly === 'undefined' || !window.recurly) {
+      setRecurlyLoadError(true)
+    }
+  })
+
+  if (!subscription) return null
 
   return (
     <>
@@ -61,6 +72,11 @@ function PersonalSubscription({
         <PastDueSubscriptionAlert subscription={subscription} />
       )}
       <PersonalSubscriptionStates subscription={subscription} state={state} />
+      {recurlyLoadError && (
+        <div className="alert alert-warning" role="alert">
+          <strong>{t('payment_provider_unreachable_error')}</strong>
+        </div>
+      )}
     </>
   )
 }

+ 0 - 111
services/web/frontend/js/features/subscription/components/dashboard/states/active.tsx

@@ -1,111 +0,0 @@
-import { useTranslation, Trans } from 'react-i18next'
-import PremiumFeaturesLink from '../premium-features-link'
-import { PriceExceptions } from '../../shared/price-exceptions'
-import { useSubscriptionDashboardContext } from '../../../context/subscription-dashboard-context'
-import { Subscription } from '../../../../../../../types/subscription/dashboard/subscription'
-
-function ChangePlan() {
-  const { t } = useTranslation()
-  const { showChangePersonalPlan } = useSubscriptionDashboardContext()
-
-  if (!showChangePersonalPlan) return null
-
-  return (
-    <>
-      <h2>{t('change_plan')}</h2>
-      <p>
-        <strong>TODO: change subscription placeholder</strong>
-      </p>
-    </>
-  )
-}
-
-export function ActiveSubsciption({
-  subscription,
-}: {
-  subscription: Subscription
-}) {
-  const { t } = useTranslation()
-  const { setShowChangePersonalPlan } = useSubscriptionDashboardContext()
-
-  return (
-    <>
-      <p>
-        <Trans
-          i18nKey="currently_subscribed_to_plan"
-          values={{
-            planName: subscription.plan.name,
-          }}
-          components={[
-            // eslint-disable-next-line react/jsx-key
-            <strong />,
-          ]}
-        />
-        {subscription.pendingPlan &&
-          subscription.pendingPlan.name !== subscription.plan.name && (
-            <>
-              {' '}
-              <Trans
-                i18nKey="your_plan_is_changing_at_term_end"
-                values={{
-                  pendingPlanName: subscription.pendingPlan.name,
-                }}
-                components={[
-                  // eslint-disable-next-line react/jsx-key
-                  <strong />,
-                ]}
-              />
-            </>
-          )}{' '}
-        {/* TODO: pending_additional_licenses */}
-        {/* TODO: additionalLicenses */}
-        <button
-          className="btn-inline-link"
-          onClick={() => setShowChangePersonalPlan(true)}
-        >
-          {t('change_plan')}
-        </button>
-      </p>
-      {subscription.pendingPlan && (
-        <p>{t('want_change_to_apply_before_plan_end')}</p>
-      )}
-      {/* TODO: groupPlan */}
-      {/* TODO: trialEndsAtFormatted */}
-      <p>
-        <Trans
-          i18nKey="next_payment_of_x_collectected_on_y"
-          values={{
-            paymentAmmount: subscription.recurly.displayPrice,
-            collectionDate: subscription.recurly.nextPaymentDueAt,
-          }}
-          components={[
-            // eslint-disable-next-line react/jsx-key
-            <strong />,
-            // eslint-disable-next-line react/jsx-key
-            <strong />,
-          ]}
-        />
-      </p>
-      <PremiumFeaturesLink />
-      <PriceExceptions />
-      <a
-        href={subscription.recurly.billingDetailsLink}
-        target="_blank"
-        rel="noreferrer noopener"
-        className="btn btn-secondary-info btn-secondary"
-      >
-        {t('update_your_billing_details')}
-      </a>{' '}
-      <a
-        href={subscription.recurly.accountManagementLink}
-        target="_blank"
-        rel="noreferrer noopener"
-        className="btn btn-secondary-info btn-secondary"
-      >
-        {t('view_your_invoices')}
-      </a>{' '}
-      {/* TODO:  cancel button */}
-      <ChangePlan />
-    </>
-  )
-}

+ 117 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx

@@ -0,0 +1,117 @@
+import { useTranslation, Trans } from 'react-i18next'
+import PremiumFeaturesLink from '../../premium-features-link'
+import { PriceExceptions } from '../../../shared/price-exceptions'
+import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
+import { Subscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+import { CancelSubscriptionButton } from './cancel-subscription-button'
+import { CancelSubscription } from './cancel-subscription'
+import { PendingPlanChange } from './pending-plan-change'
+import { TrialEnding } from './trial-ending'
+import { ChangePlan } from './change-plan'
+
+export function ActiveSubsciption({
+  subscription,
+}: {
+  subscription: Subscription
+}) {
+  const { t } = useTranslation()
+  const { recurlyLoadError, setShowChangePersonalPlan, showCancellation } =
+    useSubscriptionDashboardContext()
+
+  if (showCancellation) return <CancelSubscription />
+
+  return (
+    <>
+      <p>
+        <Trans
+          i18nKey="currently_subscribed_to_plan"
+          values={{
+            planName: subscription.plan.name,
+          }}
+          components={[
+            // eslint-disable-next-line react/jsx-key
+            <strong />,
+          ]}
+        />
+        {subscription.pendingPlan && (
+          <PendingPlanChange subscription={subscription} />
+        )}
+        {!subscription.pendingPlan &&
+          subscription.recurly.additionalLicenses > 0 && (
+            <>
+              {' '}
+              <Trans
+                i18nKey="additional_licenses"
+                values={{
+                  additionalLicenses: subscription.recurly.additionalLicenses,
+                  totalLicenses: subscription.recurly.totalLicenses,
+                }}
+                components={[
+                  // eslint-disable-next-line react/jsx-key
+                  <strong />,
+                  // eslint-disable-next-line react/jsx-key
+                  <strong />,
+                ]}
+              />
+            </>
+          )}{' '}
+        {!recurlyLoadError &&
+          !subscription.groupPlan &&
+          subscription.recurly.account.has_past_due_invoice._ !== 'true' && (
+            <button
+              className="btn-inline-link"
+              onClick={() => setShowChangePersonalPlan(true)}
+            >
+              {t('change_plan')}
+            </button>
+          )}
+      </p>
+      {subscription.pendingPlan && (
+        <p>{t('want_change_to_apply_before_plan_end')}</p>
+      )}
+      {/* TODO: groupPlan */}
+      <TrialEnding subscription={subscription} />
+      <p>
+        <Trans
+          i18nKey="next_payment_of_x_collectected_on_y"
+          values={{
+            paymentAmmount: subscription.recurly.displayPrice,
+            collectionDate: subscription.recurly.nextPaymentDueAt,
+          }}
+          components={[
+            // eslint-disable-next-line react/jsx-key
+            <strong />,
+            // eslint-disable-next-line react/jsx-key
+            <strong />,
+          ]}
+        />
+      </p>
+      <PremiumFeaturesLink />
+      <PriceExceptions />
+      <p>
+        <a
+          href={subscription.recurly.billingDetailsLink}
+          target="_blank"
+          rel="noreferrer noopener"
+          className="btn btn-secondary-info btn-secondary"
+        >
+          {t('update_your_billing_details')}
+        </a>{' '}
+        <a
+          href={subscription.recurly.accountManagementLink}
+          target="_blank"
+          rel="noreferrer noopener"
+          className="btn btn-secondary-info btn-secondary"
+        >
+          {t('view_your_invoices')}
+        </a>
+      </p>
+
+      {!recurlyLoadError && (
+        <CancelSubscriptionButton subscription={subscription} />
+      )}
+
+      <ChangePlan />
+    </>
+  )
+}

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

@@ -0,0 +1,56 @@
+import { useTranslation, Trans } from 'react-i18next'
+import * as eventTracking from '../../../../../../infrastructure/event-tracking'
+import { Subscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
+
+export function CancelSubscriptionButton({
+  subscription,
+}: {
+  subscription: Subscription
+}) {
+  const { t } = useTranslation()
+  const { recurlyLoadError, setShowCancellation } =
+    useSubscriptionDashboardContext()
+
+  const stillInATrial =
+    subscription.recurly.trialEndsAtFormatted &&
+    subscription.recurly.trial_ends_at &&
+    new Date(subscription.recurly.trial_ends_at).getTime() > Date.now()
+
+  function handleCancelSubscriptionClick() {
+    eventTracking.sendMB('subscription-page-cancel-button-click')
+    setShowCancellation(true)
+  }
+
+  if (recurlyLoadError) return null
+
+  return (
+    <>
+      <br />
+      <p>
+        <button
+          className="btn btn-danger"
+          onClick={handleCancelSubscriptionClick}
+        >
+          {t('cancel_your_subscription')}
+        </button>
+      </p>
+      {!stillInATrial && (
+        <p>
+          <i>
+            <Trans
+              i18nKey="subscription_will_remain_active_until_end_of_billing_period_x"
+              values={{
+                terminationDate: subscription.recurly.nextPaymentDueAt,
+              }}
+              components={[
+                // eslint-disable-next-line react/jsx-key
+                <strong />,
+              ]}
+            />
+          </i>
+        </p>
+      )}
+    </>
+  )
+}

+ 15 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription.tsx

@@ -0,0 +1,15 @@
+import { useTranslation } from 'react-i18next'
+
+export function CancelSubscription() {
+  const { t } = useTranslation()
+  return (
+    <div className="text-center">
+      <p>
+        <strong>{t('wed_love_you_to_stay')}</strong>
+      </p>
+      {/* todo: showExtendFreeTrial */}
+      {/* todo: showDowngrade */}
+      {/* todo: showBasicCancel */}
+    </div>
+  )
+}

+ 18 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan.tsx

@@ -0,0 +1,18 @@
+import { useTranslation } from 'react-i18next'
+import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
+
+export function ChangePlan() {
+  const { t } = useTranslation()
+  const { showChangePersonalPlan } = useSubscriptionDashboardContext()
+
+  if (!showChangePersonalPlan) return null
+
+  return (
+    <>
+      <h2>{t('change_plan')}</h2>
+      <p>
+        <strong>TODO: change subscription placeholder</strong>
+      </p>
+    </>
+  )
+}

+ 49 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/pending-plan-change.tsx

@@ -0,0 +1,49 @@
+import { Trans } from 'react-i18next'
+import { Subscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+
+export function PendingPlanChange({
+  subscription,
+}: {
+  subscription: Subscription
+}) {
+  if (!subscription.pendingPlan) return null
+
+  return (
+    <>
+      {subscription.pendingPlan.name !== subscription.plan.name && (
+        <Trans
+          i18nKey="your_plan_is_changing_at_term_end"
+          values={{
+            pendingPlanName: subscription.pendingPlan.name,
+          }}
+          components={[
+            // eslint-disable-next-line react/jsx-key
+            <strong />,
+          ]}
+        />
+      )}
+
+      {((subscription.recurly.pendingAdditionalLicenses &&
+        subscription.recurly.pendingAdditionalLicenses > 0) ||
+        subscription.recurly.additionalLicenses > 0) && (
+        <>
+          {' '}
+          <Trans
+            i18nKey="pending_additional_licenses"
+            values={{
+              pendingAdditionalLicenses:
+                subscription.recurly.pendingAdditionalLicenses,
+              pendingTotalLicenses: subscription.recurly.pendingTotalLicenses,
+            }}
+            components={[
+              // eslint-disable-next-line react/jsx-key
+              <strong />,
+              // eslint-disable-next-line react/jsx-key
+              <strong />,
+            ]}
+          />
+        </>
+      )}
+    </>
+  )
+}

+ 26 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/trial-ending.tsx

@@ -0,0 +1,26 @@
+import { Trans } from 'react-i18next'
+import { Subscription } from '../../../../../../../../types/subscription/dashboard/subscription'
+
+export function TrialEnding({ subscription }: { subscription: Subscription }) {
+  if (
+    !subscription.recurly.trialEndsAtFormatted ||
+    !subscription.recurly.trial_ends_at
+  )
+    return null
+
+  const endDate = new Date(subscription.recurly.trial_ends_at)
+  if (endDate.getTime() < Date.now()) return null
+
+  return (
+    <p>
+      <Trans
+        i18nKey="youre_on_free_trial_which_ends_on"
+        values={{ date: subscription.recurly.trialEndsAtFormatted }}
+        components={[
+          // eslint-disable-next-line react/jsx-key
+          <strong />,
+        ]}
+      />
+    </p>
+  )
+}

+ 18 - 1
services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx

@@ -1,6 +1,10 @@
 import { createContext, ReactNode, useContext, useMemo, useState } from 'react'
 
 type SubscriptionDashboardContextValue = {
+  recurlyLoadError: boolean
+  setRecurlyLoadError: React.Dispatch<React.SetStateAction<boolean>>
+  showCancellation: boolean
+  setShowCancellation: React.Dispatch<React.SetStateAction<boolean>>
   showChangePersonalPlan: boolean
   setShowChangePersonalPlan: React.Dispatch<React.SetStateAction<boolean>>
 }
@@ -14,14 +18,27 @@ export function SubscriptionDashboardProvider({
 }: {
   children: ReactNode
 }) {
+  const [recurlyLoadError, setRecurlyLoadError] = useState(false)
+  const [showCancellation, setShowCancellation] = useState(false)
   const [showChangePersonalPlan, setShowChangePersonalPlan] = useState(false)
 
   const value = useMemo<SubscriptionDashboardContextValue>(
     () => ({
+      recurlyLoadError,
+      setRecurlyLoadError,
+      showCancellation,
+      setShowCancellation,
       showChangePersonalPlan,
       setShowChangePersonalPlan,
     }),
-    [showChangePersonalPlan, setShowChangePersonalPlan]
+    [
+      recurlyLoadError,
+      setRecurlyLoadError,
+      showCancellation,
+      setShowCancellation,
+      showChangePersonalPlan,
+      setShowChangePersonalPlan,
+    ]
   )
 
   return (

+ 1 - 0
services/web/locales/en.json

@@ -1657,6 +1657,7 @@
   "your_sessions": "Your Sessions",
   "your_subscription": "Your Subscription",
   "your_subscription_has_expired": "Your subscription has expired.",
+  "youre_on_free_trial_which_ends_on": "You’re on a free trial which ends on <0>__date__</0>.",
   "zh-CN": "Chinese",
   "zip_contents_too_large": "Zip contents too large",
   "zotero": "Zotero",

+ 37 - 1
services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx

@@ -11,10 +11,12 @@ import {
 describe('<PersonalSubscription />', function () {
   beforeEach(function () {
     window.metaAttributesCache = new Map()
+    window.recurly = {}
   })
 
   afterEach(function () {
     window.metaAttributesCache = new Map()
+    delete window.recurly
   })
 
   describe('no subscription', function () {
@@ -74,7 +76,10 @@ describe('<PersonalSubscription />', function () {
     })
 
     it('renders error message when an unknown subscription state', function () {
-      const withStateDeleted = Object.assign({}, annualActiveSubscription)
+      const withStateDeleted = Object.assign(
+        {},
+        JSON.parse(JSON.stringify(annualActiveSubscription))
+      )
       withStateDeleted.recurly.state = undefined
       render(
         <SubscriptionDashboardProvider>
@@ -105,4 +110,35 @@ describe('<PersonalSubscription />', function () {
       expect(invoiceLinks.length).to.equal(2)
     })
   })
+
+  describe('Recurly JS', function () {
+    const recurlyFailedToLoadText =
+      'Sorry, there was an error talking to our payment provider. Please try again in a few moments. If you are using any ad or script blocking extensions in your browser, you may need to temporarily disable them.'
+
+    it('shows an alert and hides "Change plan" option when Recurly did not load', function () {
+      delete window.recurly
+      render(
+        <SubscriptionDashboardProvider>
+          <PersonalSubscription subscription={annualActiveSubscription} />
+        </SubscriptionDashboardProvider>
+      )
+
+      screen.getByRole('alert')
+      screen.getByText(recurlyFailedToLoadText)
+
+      expect(screen.queryByText('Change plan')).to.be.null
+    })
+
+    it('should not show an alert and should show "Change plan" option when Recurly did load', function () {
+      render(
+        <SubscriptionDashboardProvider>
+          <PersonalSubscription subscription={annualActiveSubscription} />
+        </SubscriptionDashboardProvider>
+      )
+
+      expect(screen.queryByRole('alert')).to.be.null
+
+      screen.getByText('Change plan')
+    })
+  })
 })

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

@@ -1,19 +1,29 @@
+import { expect } from 'chai'
 import { fireEvent, render, screen } from '@testing-library/react'
-import { ActiveSubsciption } from '../../../../../../../frontend/js/features/subscription/components/dashboard/states/active'
+import * as eventTracking from '../../../../../../../frontend/js/infrastructure/event-tracking'
+import { ActiveSubsciption } from '../../../../../../../frontend/js/features/subscription/components/dashboard/states/active/active'
 import { SubscriptionDashboardProvider } from '../../../../../../../frontend/js/features/subscription/context/subscription-dashboard-context'
 import { Subscription } from '../../../../../../../types/subscription/dashboard/subscription'
 import {
   annualActiveSubscription,
+  groupActiveSubscription,
+  groupActiveSubscriptionWithPendingLicenseChange,
   pendingSubscriptionChange,
+  trialSubscription,
 } from '../../../fixtures/subscriptions'
+import sinon from 'sinon'
 
 describe('<ActiveSubscription />', function () {
+  let sendMBSpy: sinon.SinonSpy
+
   beforeEach(function () {
     window.metaAttributesCache = new Map()
+    sendMBSpy = sinon.spy(eventTracking, 'sendMB')
   })
 
   afterEach(function () {
     window.metaAttributesCache = new Map()
+    sendMBSpy.restore()
   })
 
   function expectedInActiveSubscription(subscription: Subscription) {
@@ -25,13 +35,14 @@ describe('<ActiveSubscription />', function () {
 
     // sentence broken up by bolding
     screen.getByText('The next payment of', { exact: false })
-    screen.getByText(annualActiveSubscription.recurly.displayPrice, {
+    screen.getByText(subscription.recurly.displayPrice, {
       exact: false,
     })
     screen.getByText('will be collected on', { exact: false })
-    screen.getByText(annualActiveSubscription.recurly.nextPaymentDueAt, {
+    const dates = screen.getAllByText(subscription.recurly.nextPaymentDueAt, {
       exact: false,
     })
+    expect(dates.length).to.equal(2)
 
     // sentence broken up by link
     screen.getByText(
@@ -89,4 +100,145 @@ describe('<ActiveSubscription />', function () {
       exact: false,
     })
   })
+
+  it('does not show "Change plan" option for group plans', function () {
+    render(
+      <SubscriptionDashboardProvider>
+        <ActiveSubsciption subscription={groupActiveSubscription} />
+      </SubscriptionDashboardProvider>
+    )
+
+    const changePlan = screen.queryByRole('button', { name: 'Change plan' })
+    expect(changePlan).to.be.null
+  })
+
+  it('does not show "Change plan" option when past due', function () {
+    // account is likely in expired state, but be sure to not show option if state is still active
+    const activePastDueSubscription = Object.assign(
+      {},
+      annualActiveSubscription
+    )
+
+    activePastDueSubscription.recurly.account.has_past_due_invoice._ = 'true'
+
+    render(
+      <SubscriptionDashboardProvider>
+        <ActiveSubsciption subscription={activePastDueSubscription} />
+      </SubscriptionDashboardProvider>
+    )
+
+    const changePlan = screen.queryByRole('button', { name: 'Change plan' })
+    expect(changePlan).to.be.null
+  })
+
+  it('shows the pending license change message when plan change is pending', function () {
+    render(
+      <SubscriptionDashboardProvider>
+        <ActiveSubsciption
+          subscription={groupActiveSubscriptionWithPendingLicenseChange}
+        />
+      </SubscriptionDashboardProvider>
+    )
+
+    screen.getByText('Your subscription is changing to include', {
+      exact: false,
+    })
+
+    if (
+      !groupActiveSubscriptionWithPendingLicenseChange.recurly
+        .pendingAdditionalLicenses
+    ) {
+      throw Error('not expected test data')
+    }
+    screen.getByText(
+      groupActiveSubscriptionWithPendingLicenseChange.recurly
+        .pendingAdditionalLicenses
+    )
+
+    screen.getByText('additional license(s) for a total of', { exact: false })
+
+    if (
+      !groupActiveSubscriptionWithPendingLicenseChange.recurly
+        .pendingTotalLicenses
+    ) {
+      throw Error('not expected test data')
+    }
+    screen.getByText(
+      groupActiveSubscriptionWithPendingLicenseChange.recurly
+        .pendingTotalLicenses
+    )
+  })
+
+  it('shows the pending license change message when plan change is not pending', function () {
+    const subscription = Object.assign({}, groupActiveSubscription)
+    subscription.recurly.additionalLicenses = 4
+    subscription.recurly.totalLicenses =
+      subscription.recurly.totalLicenses +
+      subscription.recurly.additionalLicenses
+
+    render(
+      <SubscriptionDashboardProvider>
+        <ActiveSubsciption subscription={subscription} />
+      </SubscriptionDashboardProvider>
+    )
+
+    screen.getByText('Your subscription includes', {
+      exact: false,
+    })
+
+    if (!subscription.recurly.additionalLicenses) {
+      throw Error('not expected test data')
+    }
+    screen.getByText(subscription.recurly.additionalLicenses)
+
+    screen.getByText('additional license(s) for a total of', { exact: false })
+
+    screen.getByText(subscription.recurly.totalLicenses)
+  })
+
+  it('shows when trial ends and first payment collected', function () {
+    render(
+      <SubscriptionDashboardProvider>
+        <ActiveSubsciption subscription={trialSubscription} />
+      </SubscriptionDashboardProvider>
+    )
+    screen.getByText('You’re on a free trial which ends on', { exact: false })
+    if (!trialSubscription.recurly.trialEndsAtFormatted) {
+      throw new Error('not expected test data')
+    }
+    const endDate = screen.getAllByText(
+      trialSubscription.recurly.trialEndsAtFormatted
+    )
+    expect(endDate.length).to.equal(2)
+  })
+
+  it('shows cancel UI and sends event', function () {
+    render(
+      <SubscriptionDashboardProvider>
+        <ActiveSubsciption subscription={annualActiveSubscription} />
+      </SubscriptionDashboardProvider>
+    )
+    // before button clicked
+    screen.getByText(
+      'Your subscription will remain active until the end of your billing period',
+      { exact: false }
+    )
+    const dates = screen.getAllByText(
+      annualActiveSubscription.recurly.nextPaymentDueAt,
+      {
+        exact: false,
+      }
+    )
+    expect(dates.length).to.equal(2)
+
+    const button = screen.getByRole('button', {
+      name: 'Cancel Your Subscription',
+    })
+    fireEvent.click(button)
+    expect(sendMBSpy).to.be.calledOnceWith(
+      'subscription-page-cancel-button-click'
+    )
+
+    screen.getByText('We’d love you to stay')
+  })
 })

+ 6 - 1
services/web/test/frontend/features/subscription/components/dashboard/subscription-dashboard.test.tsx

@@ -1,6 +1,7 @@
 import { expect } from 'chai'
 import { render, screen } from '@testing-library/react'
 import SubscriptionDashboard from '../../../../../../frontend/js/features/subscription/components/dashboard/subscription-dashboard'
+import { SubscriptionDashboardProvider } from '../../../../../../frontend/js/features/subscription/context/subscription-dashboard-context'
 
 describe('<SubscriptionDashboard />', function () {
   beforeEach(function () {
@@ -13,7 +14,11 @@ describe('<SubscriptionDashboard />', function () {
 
   describe('Free Plan', function () {
     it('does not render the "Get the most out of your" subscription text', function () {
-      render(<SubscriptionDashboard />)
+      render(
+        <SubscriptionDashboardProvider>
+          <SubscriptionDashboard />
+        </SubscriptionDashboardProvider>
+      )
       const text = screen.queryByText('Get the most out of your', {
         exact: false,
       })

+ 165 - 0
services/web/test/frontend/features/subscription/fixtures/subscriptions.tsx

@@ -3,6 +3,11 @@ const dateformat = require('dateformat')
 const today = new Date()
 const oneYearFromToday = new Date().setFullYear(today.getFullYear() + 1)
 const nextPaymentDueAt = dateformat(oneYearFromToday, 'dS mmmm yyyy')
+const sevenDaysFromToday = new Date().setDate(today.getDate() + 7)
+const sevenDaysFromTodayFormatted = dateformat(
+  sevenDaysFromToday,
+  'dS mmmm yyyy'
+)
 
 export const annualActiveSubscription: Subscription = {
   manager_ids: ['abc123'],
@@ -171,3 +176,163 @@ export const pendingSubscriptionChange: Subscription = {
     featureDescription: [],
   },
 }
+
+export const groupActiveSubscription: Subscription = {
+  manager_ids: ['abc123'],
+  member_ids: ['abc123'],
+  invited_emails: [],
+  groupPlan: true,
+  membersLimit: 10,
+  _id: 'def456',
+  admin_id: 'abc123',
+  teamInvites: [],
+  planCode: 'group_collaborator_10_enterprise',
+  recurlySubscription_id: 'ghi789',
+  plan: {
+    planCode: 'group_collaborator_10_enterprise',
+    name: 'Overleaf Standard (Collaborator) - Group Account (10 licenses) - Enterprise',
+    hideFromUsers: true,
+    price_in_cents: 129000,
+    annual: true,
+    features: {},
+    groupPlan: true,
+    membersLimit: 10,
+    membersLimitAddOn: 'additional-license',
+  },
+  recurly: {
+    tax: 0,
+    taxRate: 0,
+    billingDetailsLink: '/user/subscription/recurly/billing-details',
+    accountManagementLink: '/user/subscription/recurly/account-management',
+    additionalLicenses: 0,
+    totalLicenses: 10,
+    nextPaymentDueAt,
+    currency: 'USD',
+    state: 'active',
+    trialEndsAtFormatted: null,
+    trial_ends_at: null,
+    activeCoupons: [],
+    account: {
+      has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
+      has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
+    },
+    displayPrice: '$1290.00',
+  },
+}
+
+export const groupActiveSubscriptionWithPendingLicenseChange: Subscription = {
+  manager_ids: ['abc123'],
+  member_ids: ['abc123'],
+  invited_emails: [],
+  groupPlan: true,
+  membersLimit: 10,
+  _id: 'def456',
+  admin_id: 'abc123',
+  teamInvites: [],
+  planCode: 'group_collaborator_10_enterprise',
+  recurlySubscription_id: 'ghi789',
+  plan: {
+    planCode: 'group_collaborator_10_enterprise',
+    name: 'Overleaf Standard (Collaborator) - Group Account (10 licenses) - Enterprise',
+    hideFromUsers: true,
+    price_in_cents: 129000,
+    annual: true,
+    features: {},
+    groupPlan: true,
+    membersLimit: 10,
+    membersLimitAddOn: 'additional-license',
+  },
+  recurly: {
+    tax: 0,
+    taxRate: 0,
+    billingDetailsLink: '/user/subscription/recurly/billing-details',
+    accountManagementLink: '/user/subscription/recurly/account-management',
+    additionalLicenses: 11,
+    totalLicenses: 21,
+    nextPaymentDueAt,
+    currency: 'USD',
+    state: 'active',
+    trialEndsAtFormatted: null,
+    trial_ends_at: null,
+    activeCoupons: [],
+    account: {
+      has_canceled_subscription: {
+        _: 'false',
+        $: {
+          type: 'boolean',
+        },
+      },
+      has_past_due_invoice: {
+        _: 'false',
+        $: {
+          type: 'boolean',
+        },
+      },
+    },
+    displayPrice: '$2967.00',
+    currentPlanDisplayPrice: '$2709.00',
+    pendingAdditionalLicenses: 13,
+    pendingTotalLicenses: 23,
+  },
+  pendingPlan: {
+    planCode: 'group_collaborator_10_enterprise',
+    name: 'Overleaf Standard (Collaborator) - Group Account (10 licenses) - Enterprise',
+    hideFromUsers: true,
+    price_in_cents: 129000,
+    annual: true,
+    features: {},
+    groupPlan: true,
+    membersLimit: 10,
+    membersLimitAddOn: 'additional-license',
+  },
+}
+
+export const trialSubscription: Subscription = {
+  manager_ids: ['abc123'],
+  member_ids: [],
+  invited_emails: [],
+  groupPlan: false,
+  membersLimit: 0,
+  _id: 'def456',
+  admin_id: 'abc123',
+  teamInvites: [],
+  planCode: 'paid-personal_free_trial_7_days',
+  recurlySubscription_id: 'ghi789',
+  plan: {
+    planCode: 'paid-personal_free_trial_7_days',
+    name: 'Personal',
+    price_in_cents: 1500,
+    features: {},
+    featureDescription: [],
+    hideFromUsers: true,
+  },
+  recurly: {
+    tax: 0,
+    taxRate: 0,
+    billingDetailsLink: '/user/subscription/recurly/billing-details',
+    accountManagementLink: '/user/subscription/recurly/account-management',
+    additionalLicenses: 0,
+    totalLicenses: 0,
+    nextPaymentDueAt: sevenDaysFromTodayFormatted,
+    currency: 'USD',
+    state: 'active',
+    trialEndsAtFormatted: sevenDaysFromTodayFormatted,
+    trial_ends_at: new Date(sevenDaysFromToday).toString(),
+    activeCoupons: [],
+    account: {
+      has_canceled_subscription: {
+        _: 'false',
+        $: {
+          type: 'boolean',
+        },
+      },
+      has_past_due_invoice: {
+        _: 'false',
+        $: {
+          type: 'boolean',
+        },
+      },
+    },
+    displayPrice: '$14.00',
+  },
+}

+ 4 - 1
services/web/types/subscription/dashboard/subscription.ts

@@ -7,7 +7,10 @@ type Plan = {
   annual?: boolean
   features: object
   hideFromUsers?: boolean
-  featureDescription: object[]
+  featureDescription?: object[]
+  groupPlan?: boolean
+  membersLimit?: number
+  membersLimitAddOn?: string
 }
 
 type SubscriptionState = 'active' | 'canceled' | 'expired'

+ 1 - 0
services/web/types/window.ts

@@ -31,5 +31,6 @@ declare global {
     _reportAcePerf: () => void
     MathJax: Record<string, any>
     overallThemes: OverallThemeMeta[]
+    recurly?: object
   }
 }