Parcourir la 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 il y a 3 ans
Parent
commit
2499ecadcc
19 fichiers modifiés avec 823 ajouts et 36 suppressions
  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_your_projects_with_git": "",
   "account_has_been_link_to_institution_account": "",
+  "account_has_past_due_invoice_change_plan_warning": "",
   "account_not_linked_to_dropbox": "",
   "account_settings": "",
   "acct_linked_to_institution_acct_2": "",
@@ -71,6 +72,7 @@
   "change_or_cancel-or": "",
   "change_owner": "",
   "change_password": "",
+  "change_plan": "",
   "change_primary_email_address_instructions": "",
   "change_project_owner": "",
   "chat": "",
@@ -118,10 +120,12 @@
   "create": "",
   "create_first_project": "",
   "create_new_folder": "",
+  "create_new_subscription": "",
   "create_project_in_github": "",
   "created_at": "",
   "creating": "",
   "current_password": "",
+  "currently_subscribed_to_plan": "",
   "date_and_owner": "",
   "delete": "",
   "delete_account": "",
@@ -431,6 +435,7 @@
   "new_project": "",
   "new_to_latex_look_at": "",
   "newsletter": "",
+  "next_payment_of_x_collectected_on_y": "",
   "no_existing_password": "",
   "no_messages": "",
   "no_new_commits_in_github": "",
@@ -502,6 +507,7 @@
   "priority_support": "",
   "privacy_policy": "",
   "private": "",
+  "problem_with_subscription_contact_us": "",
   "processing": "",
   "professional": "",
   "project": "",
@@ -531,6 +537,7 @@
   "push_sharelatex_changes_to_github": "",
   "raw_logs": "",
   "raw_logs_description": "",
+  "reactivate_subscription": "",
   "read_only": "",
   "read_only_token": "",
   "read_write_token": "",
@@ -652,8 +659,10 @@
   "stop_on_validation_error": "",
   "store_your_work": "",
   "subject": "",
+  "subject_to_additional_vat": "",
   "submit_title": "",
   "subscription_admins_cannot_be_deleted": "",
+  "subscription_canceled_and_terminate_on_x": "",
   "sure_you_want_to_delete": "",
   "switch_to_editor": "",
   "switch_to_pdf": "",
@@ -732,6 +741,7 @@
   "update": "",
   "update_account_info": "",
   "update_dropbox_settings": "",
+  "update_your_billing_details": "",
   "upgrade": "",
   "upgrade_for_longer_compiles": "",
   "upgrade_now": "",
@@ -749,6 +759,8 @@
   "view_all": "",
   "view_logs": "",
   "view_pdf": "",
+  "view_your_invoices": "",
+  "want_change_to_apply_before_plan_end": "",
   "we_cant_find_any_sections_or_subsections_in_this_file": "",
   "we_logged_you_in": "",
   "welcome_to_sl": "",
@@ -763,8 +775,10 @@
   "your_affiliation_is_confirmed": "",
   "your_browser_does_not_support_this_feature": "",
   "your_message_to_collaborators": "",
+  "your_plan_is_changing_at_term_end": "",
   "your_projects": "",
   "your_subscription": "",
+  "your_subscription_has_expired": "",
   "zotero_groups_loading_error": "",
   "zotero_groups_relink": "",
   "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 { Institution } from '../../../../../../types/institution'
+import PremiumFeaturesLink from './premium-features-link'
 
 type InstitutionMembershipsProps = {
   memberships?: Array<Institution>
@@ -43,6 +44,7 @@ function InstitutionMemberships({ memberships }: InstitutionMembershipsProps) {
             <hr />
           </div>
         ))}
+        {memberships.length > 0 && <PremiumFeaturesLink />}
       </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') {
     return (
-      <Trans
-        i18nKey="get_most_subscription_by_checking_features"
-        components={[featuresPageLink]}
-      />
+      <p>
+        <Trans
+          i18nKey="get_most_subscription_by_checking_features"
+          components={[featuresPageLink]}
+        />
+      </p>
     )
   }
 
   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 SubscriptionDashboard from './subscription-dashboard'
 
@@ -8,7 +9,11 @@ function Root() {
     return null
   }
 
-  return <SubscriptionDashboard />
+  return (
+    <SubscriptionDashboardProvider>
+      <SubscriptionDashboard />
+    </SubscriptionDashboardProvider>
+  )
 }
 
 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 InstitutionMemberships from './institution-memberships'
 import FreePlan from './free-plan'
-import PremiumFeaturesLink from './premium-features-link'
+import PersonalSubscription from './personal-subscription'
 
 function SubscriptionDashboard() {
   const { t } = useTranslation()
   const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence')
-  const hasDisplayedSubscription = institutionMemberships?.length > 0
+  const subscription = getMeta('ol-subscription')
+
+  const hasDisplayedSubscription =
+    institutionMemberships?.length > 0 || subscription
 
   return (
     <div className="container">
@@ -19,7 +22,8 @@ function SubscriptionDashboard() {
             </div>
 
             <InstitutionMemberships memberships={institutionMemberships} />
-            {hasDisplayedSubscription ? <PremiumFeaturesLink /> : <FreePlan />}
+            <PersonalSubscription subscription={subscription} />
+            {!hasDisplayedSubscription && <FreePlan />}
           </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",
   "raw_logs": "Raw logs",
   "raw_logs_description": "Raw logs from the LaTeX compiler",
+  "reactivate_subscription": "Reactivate your subscription",
   "read_only": "Read Only",
   "read_only_token": "Read-Only 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.'
     )
   })
+
+  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()
   })
 
-  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 () {
     it('does not render the "Get the most out of your" subscription text', function () {
       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
+}