Browse Source

Merge pull request #11340 from overleaf/jel-react-personal-subscription-dash

[web] Begin to migrate personal subscription dash commons to React

GitOrigin-RevId: 43c096bd72199c8c0a7b40c8fd84a0a12c108217
Jessica Lawshe 3 years ago
parent
commit
2499ecadcc
19 changed files with 823 additions and 36 deletions
  1. 14 0
      services/web/frontend/extracted-translations.json
  2. 2 0
      services/web/frontend/js/features/subscription/components/dashboard/institution-memberships.tsx
  3. 68 0
      services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx
  4. 12 8
      services/web/frontend/js/features/subscription/components/dashboard/premium-features-link.tsx
  5. 6 1
      services/web/frontend/js/features/subscription/components/dashboard/root.tsx
  6. 111 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active.tsx
  7. 57 0
      services/web/frontend/js/features/subscription/components/dashboard/states/canceled.tsx
  8. 28 0
      services/web/frontend/js/features/subscription/components/dashboard/states/expired.tsx
  9. 7 3
      services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx
  10. 13 0
      services/web/frontend/js/features/subscription/components/shared/price-exceptions.tsx
  11. 42 0
      services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx
  12. 1 0
      services/web/locales/en.json
  13. 7 0
      services/web/test/frontend/features/subscription/components/dashboard/institution-memberships.test.tsx
  14. 108 0
      services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx
  15. 92 0
      services/web/test/frontend/features/subscription/components/dashboard/states/active.test.tsx
  16. 21 0
      services/web/test/frontend/features/subscription/components/dashboard/states/expired.test.tsx
  17. 0 24
      services/web/test/frontend/features/subscription/components/dashboard/subscription-dashboard.test.tsx
  18. 173 0
      services/web/test/frontend/features/subscription/fixtures/subscriptions.tsx
  19. 61 0
      services/web/types/subscription/dashboard/subscription.ts

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

@@ -9,6 +9,7 @@
   "access_denied": "",
   "access_denied": "",
   "access_your_projects_with_git": "",
   "access_your_projects_with_git": "",
   "account_has_been_link_to_institution_account": "",
   "account_has_been_link_to_institution_account": "",
+  "account_has_past_due_invoice_change_plan_warning": "",
   "account_not_linked_to_dropbox": "",
   "account_not_linked_to_dropbox": "",
   "account_settings": "",
   "account_settings": "",
   "acct_linked_to_institution_acct_2": "",
   "acct_linked_to_institution_acct_2": "",
@@ -71,6 +72,7 @@
   "change_or_cancel-or": "",
   "change_or_cancel-or": "",
   "change_owner": "",
   "change_owner": "",
   "change_password": "",
   "change_password": "",
+  "change_plan": "",
   "change_primary_email_address_instructions": "",
   "change_primary_email_address_instructions": "",
   "change_project_owner": "",
   "change_project_owner": "",
   "chat": "",
   "chat": "",
@@ -118,10 +120,12 @@
   "create": "",
   "create": "",
   "create_first_project": "",
   "create_first_project": "",
   "create_new_folder": "",
   "create_new_folder": "",
+  "create_new_subscription": "",
   "create_project_in_github": "",
   "create_project_in_github": "",
   "created_at": "",
   "created_at": "",
   "creating": "",
   "creating": "",
   "current_password": "",
   "current_password": "",
+  "currently_subscribed_to_plan": "",
   "date_and_owner": "",
   "date_and_owner": "",
   "delete": "",
   "delete": "",
   "delete_account": "",
   "delete_account": "",
@@ -431,6 +435,7 @@
   "new_project": "",
   "new_project": "",
   "new_to_latex_look_at": "",
   "new_to_latex_look_at": "",
   "newsletter": "",
   "newsletter": "",
+  "next_payment_of_x_collectected_on_y": "",
   "no_existing_password": "",
   "no_existing_password": "",
   "no_messages": "",
   "no_messages": "",
   "no_new_commits_in_github": "",
   "no_new_commits_in_github": "",
@@ -502,6 +507,7 @@
   "priority_support": "",
   "priority_support": "",
   "privacy_policy": "",
   "privacy_policy": "",
   "private": "",
   "private": "",
+  "problem_with_subscription_contact_us": "",
   "processing": "",
   "processing": "",
   "professional": "",
   "professional": "",
   "project": "",
   "project": "",
@@ -531,6 +537,7 @@
   "push_sharelatex_changes_to_github": "",
   "push_sharelatex_changes_to_github": "",
   "raw_logs": "",
   "raw_logs": "",
   "raw_logs_description": "",
   "raw_logs_description": "",
+  "reactivate_subscription": "",
   "read_only": "",
   "read_only": "",
   "read_only_token": "",
   "read_only_token": "",
   "read_write_token": "",
   "read_write_token": "",
@@ -652,8 +659,10 @@
   "stop_on_validation_error": "",
   "stop_on_validation_error": "",
   "store_your_work": "",
   "store_your_work": "",
   "subject": "",
   "subject": "",
+  "subject_to_additional_vat": "",
   "submit_title": "",
   "submit_title": "",
   "subscription_admins_cannot_be_deleted": "",
   "subscription_admins_cannot_be_deleted": "",
+  "subscription_canceled_and_terminate_on_x": "",
   "sure_you_want_to_delete": "",
   "sure_you_want_to_delete": "",
   "switch_to_editor": "",
   "switch_to_editor": "",
   "switch_to_pdf": "",
   "switch_to_pdf": "",
@@ -732,6 +741,7 @@
   "update": "",
   "update": "",
   "update_account_info": "",
   "update_account_info": "",
   "update_dropbox_settings": "",
   "update_dropbox_settings": "",
+  "update_your_billing_details": "",
   "upgrade": "",
   "upgrade": "",
   "upgrade_for_longer_compiles": "",
   "upgrade_for_longer_compiles": "",
   "upgrade_now": "",
   "upgrade_now": "",
@@ -749,6 +759,8 @@
   "view_all": "",
   "view_all": "",
   "view_logs": "",
   "view_logs": "",
   "view_pdf": "",
   "view_pdf": "",
+  "view_your_invoices": "",
+  "want_change_to_apply_before_plan_end": "",
   "we_cant_find_any_sections_or_subsections_in_this_file": "",
   "we_cant_find_any_sections_or_subsections_in_this_file": "",
   "we_logged_you_in": "",
   "we_logged_you_in": "",
   "welcome_to_sl": "",
   "welcome_to_sl": "",
@@ -763,8 +775,10 @@
   "your_affiliation_is_confirmed": "",
   "your_affiliation_is_confirmed": "",
   "your_browser_does_not_support_this_feature": "",
   "your_browser_does_not_support_this_feature": "",
   "your_message_to_collaborators": "",
   "your_message_to_collaborators": "",
+  "your_plan_is_changing_at_term_end": "",
   "your_projects": "",
   "your_projects": "",
   "your_subscription": "",
   "your_subscription": "",
+  "your_subscription_has_expired": "",
   "zotero_groups_loading_error": "",
   "zotero_groups_loading_error": "",
   "zotero_groups_relink": "",
   "zotero_groups_relink": "",
   "zotero_integration": "",
   "zotero_integration": "",

+ 2 - 0
services/web/frontend/js/features/subscription/components/dashboard/institution-memberships.tsx

@@ -1,5 +1,6 @@
 import { Trans } from 'react-i18next'
 import { Trans } from 'react-i18next'
 import { Institution } from '../../../../../../types/institution'
 import { Institution } from '../../../../../../types/institution'
+import PremiumFeaturesLink from './premium-features-link'
 
 
 type InstitutionMembershipsProps = {
 type InstitutionMembershipsProps = {
   memberships?: Array<Institution>
   memberships?: Array<Institution>
@@ -43,6 +44,7 @@ function InstitutionMemberships({ memberships }: InstitutionMembershipsProps) {
             <hr />
             <hr />
           </div>
           </div>
         ))}
         ))}
+        {memberships.length > 0 && <PremiumFeaturesLink />}
       </div>
       </div>
     </>
     </>
   )
   )

+ 68 - 0
services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx

@@ -0,0 +1,68 @@
+import { useTranslation } from 'react-i18next'
+import { Subscription } from '../../../../../../types/subscription/dashboard/subscription'
+import { ActiveSubsciption } from './states/active'
+import { CanceledSubsciption } from './states/canceled'
+import { ExpiredSubsciption } from './states/expired'
+
+function PastDueSubscriptionAlert({
+  subscription,
+}: {
+  subscription: Subscription
+}) {
+  const { t } = useTranslation()
+  return (
+    <>
+      <div className="alert alert-danger" role="alert">
+        {t('account_has_past_due_invoice_change_plan_warning')}{' '}
+        <a
+          href={subscription.recurly.accountManagementLink}
+          target="_blank"
+          rel="noreferrer noopener"
+        >
+          {t('view_your_invoices')}
+        </a>
+      </div>
+    </>
+  )
+}
+
+function PersonalSubscriptionStates({
+  subscription,
+  state,
+}: {
+  subscription: Subscription
+  state?: string
+}) {
+  const { t } = useTranslation()
+
+  if (state === 'active') {
+    return <ActiveSubsciption subscription={subscription} />
+  } else if (state === 'canceled') {
+    return <CanceledSubsciption subscription={subscription} />
+  } else if (state === 'expired') {
+    return <ExpiredSubsciption subscription={subscription} />
+  } else {
+    return <>{t('problem_with_subscription_contact_us')}</>
+  }
+}
+
+function PersonalSubscription({
+  subscription,
+}: {
+  subscription?: Subscription
+}) {
+  const state = subscription?.recurly?.state
+
+  if (!subscription) return <></>
+
+  return (
+    <>
+      {subscription.recurly.account.has_past_due_invoice._ === 'true' && (
+        <PastDueSubscriptionAlert subscription={subscription} />
+      )}
+      <PersonalSubscriptionStates subscription={subscription} state={state} />
+    </>
+  )
+}
+
+export default PersonalSubscription

+ 12 - 8
services/web/frontend/js/features/subscription/components/dashboard/premium-features-link.tsx

@@ -28,18 +28,22 @@ function PremiumFeaturesLink() {
 
 
   if (featuresPageVariant === 'new') {
   if (featuresPageVariant === 'new') {
     return (
     return (
-      <Trans
-        i18nKey="get_most_subscription_by_checking_features"
-        components={[featuresPageLink]}
-      />
+      <p>
+        <Trans
+          i18nKey="get_most_subscription_by_checking_features"
+          components={[featuresPageLink]}
+        />
+      </p>
     )
     )
   }
   }
 
 
   return (
   return (
-    <Trans
-      i18nKey="get_most_subscription_by_checking_premium_features"
-      components={[featuresPageLink]}
-    />
+    <p>
+      <Trans
+        i18nKey="get_most_subscription_by_checking_premium_features"
+        components={[featuresPageLink]}
+      />
+    </p>
   )
   )
 }
 }
 
 

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

@@ -1,3 +1,4 @@
+import { SubscriptionDashboardProvider } from '../../context/subscription-dashboard-context'
 import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n'
 import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n'
 import SubscriptionDashboard from './subscription-dashboard'
 import SubscriptionDashboard from './subscription-dashboard'
 
 
@@ -8,7 +9,11 @@ function Root() {
     return null
     return null
   }
   }
 
 
-  return <SubscriptionDashboard />
+  return (
+    <SubscriptionDashboardProvider>
+      <SubscriptionDashboard />
+    </SubscriptionDashboardProvider>
+  )
 }
 }
 
 
 export default Root
 export default Root

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

@@ -0,0 +1,111 @@
+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 />
+    </>
+  )
+}

+ 57 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/canceled.tsx

@@ -0,0 +1,57 @@
+import { useTranslation, Trans } from 'react-i18next'
+import { Subscription } from '../../../../../../../types/subscription/dashboard/subscription'
+import PremiumFeaturesLink from '../premium-features-link'
+
+export function CanceledSubsciption({
+  subscription,
+}: {
+  subscription: Subscription
+}) {
+  const { t } = useTranslation()
+
+  return (
+    <>
+      <p>
+        <Trans
+          i18nKey="currently_subscribed_to_plan"
+          values={{
+            planName: subscription.plan.name,
+          }}
+          components={[
+            // eslint-disable-next-line react/jsx-key
+            <strong />,
+          ]}
+        />
+      </p>
+      <p>
+        <Trans
+          i18nKey="subscription_canceled_and_terminate_on_x"
+          values={{
+            terminateDate: subscription.recurly.nextPaymentDueAt,
+          }}
+          components={[
+            // eslint-disable-next-line react/jsx-key
+            <strong />,
+          ]}
+        />
+      </p>
+      <PremiumFeaturesLink />
+      <p>
+        <a
+          href={subscription.recurly.accountManagementLink}
+          target="_blank"
+          rel="noopener noreferrer"
+          className="btn btn-secondary-info btn-secondary"
+        >
+          {t('view_your_invoices')}
+        </a>
+      </p>
+      <form action="/user/subscription/reactivate" method="POST">
+        <input type="hidden" name="_csrf" value={window.csrfToken} />
+        <button type="submit" className="btn btn-primary">
+          {t('reactivate_subscription')}
+        </button>
+      </form>
+    </>
+  )
+}

+ 28 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/expired.tsx

@@ -0,0 +1,28 @@
+import { useTranslation } from 'react-i18next'
+import { Subscription } from '../../../../../../../types/subscription/dashboard/subscription'
+
+export function ExpiredSubsciption({
+  subscription,
+}: {
+  subscription: Subscription
+}) {
+  const { t } = useTranslation()
+  return (
+    <>
+      <p>{t('your_subscription_has_expired')}</p>
+      <p>
+        <a
+          href={subscription.recurly.accountManagementLink}
+          className="btn btn-secondary-info btn-secondary"
+          target="_blank"
+          rel="noreferrer noopener"
+        >
+          {t('view_your_invoices')}
+        </a>{' '}
+        <a href="/user/subscription/plans" className="btn btn-primary">
+          {t('create_new_subscription')}
+        </a>
+      </p>
+    </>
+  )
+}

+ 7 - 3
services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx

@@ -2,12 +2,15 @@ import { useTranslation } from 'react-i18next'
 import getMeta from '../../../../utils/meta'
 import getMeta from '../../../../utils/meta'
 import InstitutionMemberships from './institution-memberships'
 import InstitutionMemberships from './institution-memberships'
 import FreePlan from './free-plan'
 import FreePlan from './free-plan'
-import PremiumFeaturesLink from './premium-features-link'
+import PersonalSubscription from './personal-subscription'
 
 
 function SubscriptionDashboard() {
 function SubscriptionDashboard() {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence')
   const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence')
-  const hasDisplayedSubscription = institutionMemberships?.length > 0
+  const subscription = getMeta('ol-subscription')
+
+  const hasDisplayedSubscription =
+    institutionMemberships?.length > 0 || subscription
 
 
   return (
   return (
     <div className="container">
     <div className="container">
@@ -19,7 +22,8 @@ function SubscriptionDashboard() {
             </div>
             </div>
 
 
             <InstitutionMemberships memberships={institutionMemberships} />
             <InstitutionMemberships memberships={institutionMemberships} />
-            {hasDisplayedSubscription ? <PremiumFeaturesLink /> : <FreePlan />}
+            <PersonalSubscription subscription={subscription} />
+            {!hasDisplayedSubscription && <FreePlan />}
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>

+ 13 - 0
services/web/frontend/js/features/subscription/components/shared/price-exceptions.tsx

@@ -0,0 +1,13 @@
+import { useTranslation } from 'react-i18next'
+
+export function PriceExceptions() {
+  const { t } = useTranslation()
+  return (
+    <>
+      <p>
+        <i>* {t('subject_to_additional_vat')}</i>
+      </p>
+      {/* TODO: activeCoupons */}
+    </>
+  )
+}

+ 42 - 0
services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx

@@ -0,0 +1,42 @@
+import { createContext, ReactNode, useContext, useMemo, useState } from 'react'
+
+type SubscriptionDashboardContextValue = {
+  showChangePersonalPlan: boolean
+  setShowChangePersonalPlan: React.Dispatch<React.SetStateAction<boolean>>
+}
+
+export const SubscriptionDashboardContext = createContext<
+  SubscriptionDashboardContextValue | undefined
+>(undefined)
+
+export function SubscriptionDashboardProvider({
+  children,
+}: {
+  children: ReactNode
+}) {
+  const [showChangePersonalPlan, setShowChangePersonalPlan] = useState(false)
+
+  const value = useMemo<SubscriptionDashboardContextValue>(
+    () => ({
+      showChangePersonalPlan,
+      setShowChangePersonalPlan,
+    }),
+    [showChangePersonalPlan, setShowChangePersonalPlan]
+  )
+
+  return (
+    <SubscriptionDashboardContext.Provider value={value}>
+      {children}
+    </SubscriptionDashboardContext.Provider>
+  )
+}
+
+export function useSubscriptionDashboardContext() {
+  const context = useContext(SubscriptionDashboardContext)
+  if (!context) {
+    throw new Error(
+      'SubscriptionDashboardContext is only available inside SubscriptionDashboardProvider'
+    )
+  }
+  return context
+}

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

@@ -1137,6 +1137,7 @@
   "quoted_text_in": "Quoted text in",
   "quoted_text_in": "Quoted text in",
   "raw_logs": "Raw logs",
   "raw_logs": "Raw logs",
   "raw_logs_description": "Raw logs from the LaTeX compiler",
   "raw_logs_description": "Raw logs from the LaTeX compiler",
+  "reactivate_subscription": "Reactivate your subscription",
   "read_only": "Read Only",
   "read_only": "Read Only",
   "read_only_token": "Read-Only Token",
   "read_only_token": "Read-Only Token",
   "read_write_token": "Read-Write Token",
   "read_write_token": "Read-Write Token",

+ 7 - 0
services/web/test/frontend/features/subscription/components/dashboard/institution-memberships.test.tsx

@@ -55,4 +55,11 @@ describe('<InstitutionMemberships />', function () {
       'Sorry, something went wrong. Subscription information related to institutional affiliations may not be displayed. Please try again later.'
       'Sorry, something went wrong. Subscription information related to institutional affiliations may not be displayed. Please try again later.'
     )
     )
   })
   })
+
+  it('renders the "Get the most out of your" subscription text when a user has a subscription', function () {
+    render(<InstitutionMemberships memberships={memberships} />)
+    screen.getByText('Get the most out of your', {
+      exact: false,
+    })
+  })
 })
 })

+ 108 - 0
services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx

@@ -0,0 +1,108 @@
+import { expect } from 'chai'
+import { render, screen } from '@testing-library/react'
+import PersonalSubscription from '../../../../../../frontend/js/features/subscription/components/dashboard/personal-subscription'
+import { SubscriptionDashboardProvider } from '../../../../../../frontend/js/features/subscription/context/subscription-dashboard-context'
+import {
+  annualActiveSubscription,
+  canceledSubscription,
+  pastDueExpiredSubscription,
+} from '../../fixtures/subscriptions'
+
+describe('<PersonalSubscription />', function () {
+  beforeEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  describe('no subscription', function () {
+    it('returns empty container', function () {
+      const { container } = render(
+        <SubscriptionDashboardProvider>
+          <PersonalSubscription subscription={undefined} />
+        </SubscriptionDashboardProvider>
+      )
+      expect(container.firstChild).to.be.null
+    })
+  })
+
+  describe('subscription states  ', function () {
+    it('renders the active dash', function () {
+      render(
+        <SubscriptionDashboardProvider>
+          <PersonalSubscription subscription={annualActiveSubscription} />
+        </SubscriptionDashboardProvider>
+      )
+
+      screen.getByText('You are currently subscribed to the', { exact: false })
+    })
+
+    it('renders the canceled dash', function () {
+      render(
+        <SubscriptionDashboardProvider>
+          <PersonalSubscription subscription={canceledSubscription} />
+        </SubscriptionDashboardProvider>
+      )
+      screen.getByText(
+        'Your subscription has been canceled and will terminate on',
+        { exact: false }
+      )
+      screen.getByText(canceledSubscription.recurly.nextPaymentDueAt, {
+        exact: false,
+      })
+
+      screen.getByText('No further payments will be taken.', { exact: false })
+
+      screen.getByText(
+        'Get the most out of your Overleaf subscription by checking out the list of',
+        { exact: false }
+      )
+
+      screen.getByRole('link', { name: 'View Your Invoices' })
+      screen.getByRole('button', { name: 'Reactivate your subscription' })
+    })
+
+    it('renders the expired dash', function () {
+      render(
+        <SubscriptionDashboardProvider>
+          <PersonalSubscription subscription={pastDueExpiredSubscription} />
+        </SubscriptionDashboardProvider>
+      )
+      screen.getByText('Your subscription has expired.')
+    })
+
+    it('renders error message when an unknown subscription state', function () {
+      const withStateDeleted = Object.assign({}, annualActiveSubscription)
+      withStateDeleted.recurly.state = undefined
+      render(
+        <SubscriptionDashboardProvider>
+          <PersonalSubscription subscription={withStateDeleted} />
+        </SubscriptionDashboardProvider>
+      )
+      screen.getByText(
+        'There is a problem with your subscription. Please contact us for more information.'
+      )
+    })
+  })
+
+  describe('past due subscription', function () {
+    it('renders error alert', function () {
+      render(
+        <SubscriptionDashboardProvider>
+          <PersonalSubscription subscription={pastDueExpiredSubscription} />
+        </SubscriptionDashboardProvider>
+      )
+      screen.getByRole('alert')
+      screen.getByText(
+        'Your account currently has a past due invoice. You will not be able to change your plan until this is resolved.',
+        { exact: false }
+      )
+      const invoiceLinks = screen.getAllByText('View Your Invoices', {
+        exact: false,
+      })
+      expect(invoiceLinks.length).to.equal(2)
+    })
+  })
+})

+ 92 - 0
services/web/test/frontend/features/subscription/components/dashboard/states/active.test.tsx

@@ -0,0 +1,92 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { ActiveSubsciption } from '../../../../../../../frontend/js/features/subscription/components/dashboard/states/active'
+import { SubscriptionDashboardProvider } from '../../../../../../../frontend/js/features/subscription/context/subscription-dashboard-context'
+import { Subscription } from '../../../../../../../types/subscription/dashboard/subscription'
+import {
+  annualActiveSubscription,
+  pendingSubscriptionChange,
+} from '../../../fixtures/subscriptions'
+
+describe('<ActiveSubscription />', function () {
+  beforeEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  function expectedInActiveSubscription(subscription: Subscription) {
+    // sentence broken up by bolding
+    screen.getByText('You are currently subscribed to the', { exact: false })
+    screen.getByText(subscription.plan.name, { exact: false })
+
+    screen.getByRole('button', { name: 'Change plan' })
+
+    // sentence broken up by bolding
+    screen.getByText('The next payment of', { exact: false })
+    screen.getByText(annualActiveSubscription.recurly.displayPrice, {
+      exact: false,
+    })
+    screen.getByText('will be collected on', { exact: false })
+    screen.getByText(annualActiveSubscription.recurly.nextPaymentDueAt, {
+      exact: false,
+    })
+
+    // sentence broken up by link
+    screen.getByText(
+      'Get the most out of your Overleaf subscription by checking out the list of',
+      { exact: false }
+    )
+
+    screen.getByText(
+      '* Prices may be subject to additional VAT, depending on your country.'
+    )
+
+    screen.getByRole('link', { name: 'Update Your Billing Details' })
+    screen.getByRole('link', { name: 'View Your Invoices' })
+  }
+
+  it('renders the dash annual active subscription', function () {
+    render(
+      <SubscriptionDashboardProvider>
+        <ActiveSubsciption subscription={annualActiveSubscription} />
+      </SubscriptionDashboardProvider>
+    )
+    expectedInActiveSubscription(annualActiveSubscription)
+  })
+
+  it('shows change plan UI when button clicked', function () {
+    render(
+      <SubscriptionDashboardProvider>
+        <ActiveSubsciption subscription={annualActiveSubscription} />
+      </SubscriptionDashboardProvider>
+    )
+
+    const button = screen.getByRole('button', { name: 'Change plan' })
+    fireEvent.click(button)
+
+    // confirm main dash UI UI still shown
+    expectedInActiveSubscription(annualActiveSubscription)
+
+    // TODO: add change plan UI
+    screen.getByText('change subscription placeholder', { exact: false })
+  })
+
+  it('notes when user is changing plan at end of current plan term', function () {
+    render(
+      <SubscriptionDashboardProvider>
+        <ActiveSubsciption subscription={pendingSubscriptionChange} />
+      </SubscriptionDashboardProvider>
+    )
+
+    expectedInActiveSubscription(pendingSubscriptionChange)
+
+    screen.getByText('Your plan is changing to', { exact: false })
+
+    screen.getByText(pendingSubscriptionChange.pendingPlan!.name)
+    screen.getByText(' at the end of the current billing period', {
+      exact: false,
+    })
+  })
+})

+ 21 - 0
services/web/test/frontend/features/subscription/components/dashboard/states/expired.test.tsx

@@ -0,0 +1,21 @@
+import { render, screen } from '@testing-library/react'
+import { ExpiredSubsciption } from '../../../../../../../frontend/js/features/subscription/components/dashboard/states/expired'
+import { pastDueExpiredSubscription } from '../../../fixtures/subscriptions'
+
+describe('<ExpiredSubsciption />', function () {
+  beforeEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  it('renders the invoices link', function () {
+    render(<ExpiredSubsciption subscription={pastDueExpiredSubscription} />)
+
+    screen.getByText('View Your Invoices', {
+      exact: false,
+    })
+  })
+})

+ 0 - 24
services/web/test/frontend/features/subscription/components/dashboard/subscription-dashboard.test.tsx

@@ -11,30 +11,6 @@ describe('<SubscriptionDashboard />', function () {
     window.metaAttributesCache = new Map()
     window.metaAttributesCache = new Map()
   })
   })
 
 
-  describe('Institution affiliation with commons', function () {
-    beforeEach(function () {
-      window.metaAttributesCache.set('ol-currentInstitutionsWithLicence', [
-        {
-          id: 9258,
-          name: 'Test University',
-          commonsAccount: true,
-          isUniversity: true,
-          confirmed: true,
-          ssoBeta: false,
-          ssoEnabled: false,
-          maxConfirmationMonths: 6,
-        },
-      ])
-    })
-
-    it('renders the "Get the most out of your" subscription text when a user has a subscription', function () {
-      render(<SubscriptionDashboard />)
-      screen.getByText('Get the most out of your', {
-        exact: false,
-      })
-    })
-  })
-
   describe('Free Plan', function () {
   describe('Free Plan', function () {
     it('does not render the "Get the most out of your" subscription text', function () {
     it('does not render the "Get the most out of your" subscription text', function () {
       render(<SubscriptionDashboard />)
       render(<SubscriptionDashboard />)

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

@@ -0,0 +1,173 @@
+import { Subscription } from '../../../../../types/subscription/dashboard/subscription'
+const dateformat = require('dateformat')
+const today = new Date()
+const oneYearFromToday = new Date().setFullYear(today.getFullYear() + 1)
+const nextPaymentDueAt = dateformat(oneYearFromToday, 'dS mmmm yyyy')
+
+export const annualActiveSubscription: Subscription = {
+  manager_ids: ['abc123'],
+  member_ids: [],
+  invited_emails: [],
+  groupPlan: false,
+  membersLimit: 0,
+  _id: 'def456',
+  admin_id: 'abc123',
+  teamInvites: [],
+  planCode: 'collaborator-annual',
+  recurlySubscription_id: 'ghi789',
+  plan: {
+    planCode: 'collaborator-annual',
+    name: 'Standard (Collaborator) Annual',
+    price_in_cents: 21900,
+    annual: true,
+    features: {},
+    featureDescription: [],
+  },
+  recurly: {
+    tax: 0,
+    taxRate: 0,
+    billingDetailsLink: '/user/subscription/recurly/billing-details',
+    accountManagementLink: '/user/subscription/recurly/account-management',
+    additionalLicenses: 0,
+    totalLicenses: 0,
+    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: '$199.00',
+  },
+}
+
+export const pastDueExpiredSubscription: Subscription = {
+  manager_ids: ['abc123'],
+  member_ids: [],
+  invited_emails: [],
+  groupPlan: false,
+  membersLimit: 0,
+  _id: 'def456',
+  admin_id: 'abc123',
+  teamInvites: [],
+  planCode: 'collaborator-annual',
+  recurlySubscription_id: 'ghi789',
+  plan: {
+    planCode: 'collaborator-annual',
+    name: 'Standard (Collaborator) Annual',
+    price_in_cents: 21900,
+    annual: true,
+    features: {},
+    featureDescription: [],
+  },
+  recurly: {
+    tax: 0,
+    taxRate: 0,
+    billingDetailsLink: '/user/subscription/recurly/billing-details',
+    accountManagementLink: '/user/subscription/recurly/account-management',
+    additionalLicenses: 0,
+    totalLicenses: 0,
+    nextPaymentDueAt,
+    currency: 'USD',
+    state: 'expired',
+    trialEndsAtFormatted: null,
+    trial_ends_at: null,
+    activeCoupons: [],
+    account: {
+      has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
+      has_past_due_invoice: { _: 'true', $: { type: 'boolean' } },
+    },
+    displayPrice: '$199.00',
+  },
+}
+
+export const canceledSubscription: Subscription = {
+  manager_ids: ['abc123'],
+  member_ids: [],
+  invited_emails: [],
+  groupPlan: false,
+  membersLimit: 0,
+  _id: 'def456',
+  admin_id: 'abc123',
+  teamInvites: [],
+  planCode: 'collaborator-annual',
+  recurlySubscription_id: 'ghi789',
+  plan: {
+    planCode: 'collaborator-annual',
+    name: 'Standard (Collaborator) Annual',
+    price_in_cents: 21900,
+    annual: true,
+    features: {},
+    featureDescription: [],
+  },
+  recurly: {
+    tax: 0,
+    taxRate: 0,
+    billingDetailsLink: '/user/subscription/recurly/billing-details',
+    accountManagementLink: '/user/subscription/recurly/account-management',
+    additionalLicenses: 0,
+    totalLicenses: 0,
+    nextPaymentDueAt,
+    currency: 'USD',
+    state: 'canceled',
+    trialEndsAtFormatted: null,
+    trial_ends_at: null,
+    activeCoupons: [],
+    account: {
+      has_canceled_subscription: { _: 'true', $: { type: 'boolean' } },
+      has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
+    },
+    displayPrice: '$199.00',
+  },
+}
+
+export const pendingSubscriptionChange: Subscription = {
+  manager_ids: ['abc123'],
+  member_ids: [],
+  invited_emails: [],
+  groupPlan: false,
+  membersLimit: 0,
+  _id: 'def456',
+  admin_id: 'abc123',
+  teamInvites: [],
+  planCode: 'collaborator-annual',
+  recurlySubscription_id: 'ghi789',
+  plan: {
+    planCode: 'collaborator-annual',
+    name: 'Standard (Collaborator) Annual',
+    price_in_cents: 21900,
+    annual: true,
+    features: {},
+    featureDescription: [],
+  },
+  recurly: {
+    tax: 0,
+    taxRate: 0,
+    billingDetailsLink: '/user/subscription/recurly/billing-details',
+    accountManagementLink: '/user/subscription/recurly/account-management',
+    additionalLicenses: 0,
+    totalLicenses: 0,
+    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: '$199.00',
+  },
+  pendingPlan: {
+    planCode: 'professional-annual',
+    name: 'Professional Annual',
+    price_in_cents: 42900,
+    annual: true,
+    features: {},
+    featureDescription: [],
+  },
+}

+ 61 - 0
services/web/types/subscription/dashboard/subscription.ts

@@ -0,0 +1,61 @@
+import { Nullable } from '../../utils'
+
+type Plan = {
+  planCode: string
+  name: string
+  price_in_cents: number
+  annual?: boolean
+  features: object
+  hideFromUsers?: boolean
+  featureDescription: object[]
+}
+
+type SubscriptionState = 'active' | 'canceled' | 'expired'
+
+export type Subscription = {
+  _id: string
+  admin_id: string
+  manager_ids: string[]
+  member_ids: string[]
+  invited_emails: string[]
+  groupPlan: boolean
+  membersLimit: number
+  teamInvites: object[]
+  planCode: string
+  recurlySubscription_id: string
+  plan: Plan
+  recurly: {
+    tax: number
+    taxRate: number
+    billingDetailsLink: string
+    accountManagementLink: string
+    additionalLicenses: number
+    totalLicenses: number
+    nextPaymentDueAt: string
+    currency: string
+    state?: SubscriptionState
+    trialEndsAtFormatted: Nullable<string>
+    trial_ends_at: Nullable<string>
+    activeCoupons: any[] // TODO: confirm type in array
+    account: {
+      // data via Recurly API
+      has_canceled_subscription: {
+        _: 'false' | 'true'
+        $: {
+          type: 'boolean'
+        }
+      }
+      has_past_due_invoice: {
+        _: 'false' | 'true'
+        $: {
+          type: 'boolean'
+        }
+      }
+    }
+    displayPrice: string
+    currentPlanDisplayPrice?: string
+    pendingAdditionalLicenses?: number
+    pendingTotalLicenses?: number
+  }
+  pendingPlan?: Plan
+}