Browse Source

Merge pull request #11509 from overleaf/jel-react-personal-subscription-dash-pt-4

[web] Continue migration of personal subscription dash to React

GitOrigin-RevId: f2d913099d727725f04697003516c90616faf014
Jessica Lawshe 3 years ago
parent
commit
c36b872ae3
20 changed files with 664 additions and 113 deletions
  1. 6 4
      services/web/app/src/Features/Subscription/SubscriptionController.js
  2. 27 0
      services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
  3. 2 1
      services/web/app/views/subscriptions/dashboard-react.pug
  4. 8 0
      services/web/frontend/extracted-translations.json
  5. 6 7
      services/web/frontend/js/features/subscription/components/dashboard/institution-memberships.tsx
  6. 5 17
      services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx
  7. 7 10
      services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx
  8. 19 25
      services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx
  9. 104 5
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan.tsx
  10. 15 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/contact-support-to-change-group-plan.tsx
  11. 28 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/pending-additional-licenses.tsx
  12. 5 14
      services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx
  13. 32 0
      services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx
  14. 26 3
      services/web/test/frontend/features/subscription/components/dashboard/institution-memberships.test.tsx
  15. 15 5
      services/web/test/frontend/features/subscription/components/dashboard/managed-group-subscriptions.test.tsx
  16. 30 8
      services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx
  17. 36 14
      services/web/test/frontend/features/subscription/components/dashboard/states/active.test.tsx
  18. 71 0
      services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan.test.tsx
  19. 215 0
      services/web/test/frontend/features/subscription/fixtures/plans.tsx
  20. 7 0
      services/web/types/subscription/dashboard/subscription.ts

+ 6 - 4
services/web/app/src/Features/Subscription/SubscriptionController.js

@@ -311,9 +311,10 @@ async function _userSubscriptionReactPage(req, res) {
   const hasSubscription =
   const hasSubscription =
     await LimitationsManager.promises.userHasV1OrV2Subscription(user)
     await LimitationsManager.promises.userHasV1OrV2Subscription(user)
   const fromPlansPage = req.query.hasSubscription
   const fromPlansPage = req.query.hasSubscription
-  const plans = SubscriptionViewModelBuilder.buildPlansList(
-    personalSubscription ? personalSubscription.plan : undefined
-  )
+  const plansData =
+    SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash(
+      personalSubscription?.plan
+    )
 
 
   AnalyticsManager.recordEventForSession(req.session, 'subscription-page-view')
   AnalyticsManager.recordEventForSession(req.session, 'subscription-page-view')
 
 
@@ -327,7 +328,8 @@ async function _userSubscriptionReactPage(req, res) {
 
 
   const data = {
   const data = {
     title: 'your_subscription',
     title: 'your_subscription',
-    plans,
+    plans: plansData?.plans,
+    planCodesChangingAtTermEnd: plansData?.planCodesChangingAtTermEnd,
     groupPlans: GroupPlansData,
     groupPlans: GroupPlansData,
     user,
     user,
     hasSubscription,
     hasSubscription,

+ 27 - 0
services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js

@@ -531,9 +531,36 @@ function buildGroupSubscriptionForView(groupSubscription) {
   }
   }
 }
 }
 
 
+function buildPlansListForSubscriptionDash(currentPlan) {
+  const allPlansData = buildPlansList(currentPlan)
+  const plans = []
+  // only list individual and visible plans for "change plans" UI
+  if (allPlansData.studentAccounts) {
+    plans.push(
+      ...allPlansData.studentAccounts.filter(plan => !plan.hideFromUsers)
+    )
+  }
+  if (allPlansData.individualMonthlyPlans) {
+    plans.push(
+      ...allPlansData.individualMonthlyPlans.filter(plan => !plan.hideFromUsers)
+    )
+  }
+  if (allPlansData.individualAnnualPlans) {
+    plans.push(
+      ...allPlansData.individualAnnualPlans.filter(plan => !plan.hideFromUsers)
+    )
+  }
+
+  return {
+    plans,
+    planCodesChangingAtTermEnd: allPlansData.planCodesChangingAtTermEnd,
+  }
+}
+
 module.exports = {
 module.exports = {
   buildUsersSubscriptionViewModel,
   buildUsersSubscriptionViewModel,
   buildPlansList,
   buildPlansList,
+  buildPlansListForSubscriptionDash,
   getBestSubscription: callbackify(getBestSubscription),
   getBestSubscription: callbackify(getBestSubscription),
   promises: {
   promises: {
     buildUsersSubscriptionViewModel: promisify(buildUsersSubscriptionViewModel),
     buildUsersSubscriptionViewModel: promisify(buildUsersSubscriptionViewModel),

+ 2 - 1
services/web/app/views/subscriptions/dashboard-react.pug

@@ -9,8 +9,9 @@ block head-scripts
 block append meta
 block append meta
 	meta(name="ol-managedInstitutions", data-type="json", content=managedInstitutions)
 	meta(name="ol-managedInstitutions", data-type="json", content=managedInstitutions)
 	meta(name="ol-managedGroupSubscriptions", data-type="json" content=managedGroupSubscriptions)
 	meta(name="ol-managedGroupSubscriptions", data-type="json" content=managedGroupSubscriptions)
-	meta(name="ol-planCodesChangingAtTermEnd", data-type="json", content=plans.planCodesChangingAtTermEnd)
+	meta(name="ol-planCodesChangingAtTermEnd", data-type="json", content=planCodesChangingAtTermEnd)
 	meta(name="ol-currentInstitutionsWithLicence", data-type="json" content=currentInstitutionsWithLicence)
 	meta(name="ol-currentInstitutionsWithLicence", data-type="json" content=currentInstitutionsWithLicence)
+	meta(name="ol-plans", data-type="json" content=plans)
 	if (personalSubscription && personalSubscription.recurly)
 	if (personalSubscription && personalSubscription.recurly)
 		meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey)
 		meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey)
 		meta(name="ol-subscription" data-type="json" content=personalSubscription)
 		meta(name="ol-subscription" data-type="json" content=personalSubscription)

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

@@ -92,6 +92,7 @@
   "change_plan": "",
   "change_plan": "",
   "change_primary_email_address_instructions": "",
   "change_primary_email_address_instructions": "",
   "change_project_owner": "",
   "change_project_owner": "",
+  "change_to_this_plan": "",
   "chat": "",
   "chat": "",
   "chat_error": "",
   "chat_error": "",
   "checking_dropbox_status": "",
   "checking_dropbox_status": "",
@@ -128,6 +129,7 @@
   "connected_users": "",
   "connected_users": "",
   "contact_message_label": "",
   "contact_message_label": "",
   "contact_sales": "",
   "contact_sales": "",
+  "contact_support_to_change_group_subscription": "",
   "contact_us": "",
   "contact_us": "",
   "continue_github_merge": "",
   "continue_github_merge": "",
   "copy": "",
   "copy": "",
@@ -374,6 +376,7 @@
   "is_email_affiliated": "",
   "is_email_affiliated": "",
   "join_project": "",
   "join_project": "",
   "joining": "",
   "joining": "",
+  "keep_current_plan": "",
   "keybindings": "",
   "keybindings": "",
   "labs_program_already_participating": "",
   "labs_program_already_participating": "",
   "labs_program_benefits": "<0></0>",
   "labs_program_benefits": "<0></0>",
@@ -450,6 +453,7 @@
   "mendeley_reference_loading_error_forbidden": "",
   "mendeley_reference_loading_error_forbidden": "",
   "mendeley_sync_description": "",
   "mendeley_sync_description": "",
   "menu": "",
   "menu": "",
+  "month": "",
   "more": "",
   "more": "",
   "n_items": "",
   "n_items": "",
   "n_items_plural": "",
   "n_items_plural": "",
@@ -541,6 +545,7 @@
   "premium_feature": "",
   "premium_feature": "",
   "premium_plan_label": "",
   "premium_plan_label": "",
   "press_shortcut_to_open_advanced_reference_search": "",
   "press_shortcut_to_open_advanced_reference_search": "",
+  "price": "",
   "priority_support": "",
   "priority_support": "",
   "privacy_policy": "",
   "privacy_policy": "",
   "private": "",
   "private": "",
@@ -825,6 +830,7 @@
   "word_count": "",
   "word_count": "",
   "work_offline": "",
   "work_offline": "",
   "work_with_non_overleaf_users": "",
   "work_with_non_overleaf_users": "",
+  "year": "",
   "you_are_a_manager_and_member_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
   "you_are_a_manager_and_member_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
   "you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
   "you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
   "you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "",
   "you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "",
@@ -834,6 +840,8 @@
   "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_new_plan": "",
+  "your_plan": "",
   "your_plan_is_changing_at_term_end": "",
   "your_plan_is_changing_at_term_end": "",
   "your_projects": "",
   "your_projects": "",
   "your_subscription": "",
   "your_subscription": "",

+ 6 - 7
services/web/frontend/js/features/subscription/components/dashboard/institution-memberships.tsx

@@ -1,15 +1,14 @@
 import { Trans } from 'react-i18next'
 import { Trans } from 'react-i18next'
 import { Institution } from '../../../../../../types/institution'
 import { Institution } from '../../../../../../types/institution'
+import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
 import PremiumFeaturesLink from './premium-features-link'
 import PremiumFeaturesLink from './premium-features-link'
 
 
-type InstitutionMembershipsProps = {
-  memberships?: Array<Institution>
-}
+function InstitutionMemberships() {
+  const { institutionMemberships } = useSubscriptionDashboardContext()
 
 
-function InstitutionMemberships({ memberships }: InstitutionMembershipsProps) {
   // memberships is undefined when data failed to load. If user has no memberships, then an empty array is returned
   // memberships is undefined when data failed to load. If user has no memberships, then an empty array is returned
 
 
-  if (!memberships) {
+  if (!institutionMemberships) {
     return (
     return (
       <div className="alert alert-warning">
       <div className="alert alert-warning">
         <p>
         <p>
@@ -24,7 +23,7 @@ function InstitutionMemberships({ memberships }: InstitutionMembershipsProps) {
   return (
   return (
     <>
     <>
       <div>
       <div>
-        {memberships.map((institution: Institution) => (
+        {institutionMemberships.map((institution: Institution) => (
           <div key={`${institution.id}`}>
           <div key={`${institution.id}`}>
             <Trans
             <Trans
               i18nKey="you_are_on_x_plan_as_a_confirmed_member_of_institution_y"
               i18nKey="you_are_on_x_plan_as_a_confirmed_member_of_institution_y"
@@ -44,7 +43,7 @@ function InstitutionMemberships({ memberships }: InstitutionMembershipsProps) {
             <hr />
             <hr />
           </div>
           </div>
         ))}
         ))}
-        {memberships.length > 0 && <PremiumFeaturesLink />}
+        {institutionMemberships.length > 0 && <PremiumFeaturesLink />}
       </div>
       </div>
     </>
     </>
   )
   )

+ 5 - 17
services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx

@@ -1,29 +1,17 @@
 import { Trans, useTranslation } from 'react-i18next'
 import { Trans, useTranslation } from 'react-i18next'
-import { GroupSubscription } from '../../../../../../types/subscription/dashboard/subscription'
-import { User } from '../../../../../../types/user'
+import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
 
 
-export type ManagedGroupSubscription = Omit<GroupSubscription, 'admin_id'> & {
-  userIsGroupMember: boolean
-  planLevelName: string
-  admin_id: User
-}
-
-type ManagedGroupSubscriptionsProps = {
-  subscriptions?: ManagedGroupSubscription[]
-}
-
-export default function ManagedGroupSubscriptions({
-  subscriptions,
-}: ManagedGroupSubscriptionsProps) {
+export default function ManagedGroupSubscriptions() {
   const { t } = useTranslation()
   const { t } = useTranslation()
+  const { managedGroupSubscriptions } = useSubscriptionDashboardContext()
 
 
-  if (!subscriptions) {
+  if (!managedGroupSubscriptions) {
     return null
     return null
   }
   }
 
 
   return (
   return (
     <>
     <>
-      {subscriptions.map(subscription => (
+      {managedGroupSubscriptions.map(subscription => (
         <div key={`managed-group-${subscription._id}`}>
         <div key={`managed-group-${subscription._id}`}>
           <p>
           <p>
             {subscription.userIsGroupMember ? (
             {subscription.userIsGroupMember ? (

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

@@ -47,13 +47,9 @@ function PersonalSubscriptionStates({
   }
   }
 }
 }
 
 
-function PersonalSubscription({
-  subscription,
-}: {
-  subscription?: Subscription
-}) {
+function PersonalSubscription() {
   const { t } = useTranslation()
   const { t } = useTranslation()
-  const { recurlyLoadError, setRecurlyLoadError } =
+  const { personalSubscription, recurlyLoadError, setRecurlyLoadError } =
     useSubscriptionDashboardContext()
     useSubscriptionDashboardContext()
 
 
   useEffect(() => {
   useEffect(() => {
@@ -62,14 +58,15 @@ function PersonalSubscription({
     }
     }
   })
   })
 
 
-  if (!subscription) return null
+  if (!personalSubscription) return null
 
 
   return (
   return (
     <>
     <>
-      {subscription.recurly.account.has_past_due_invoice._ === 'true' && (
-        <PastDueSubscriptionAlert subscription={subscription} />
+      {personalSubscription.recurly.account.has_past_due_invoice._ ===
+        'true' && (
+        <PastDueSubscriptionAlert subscription={personalSubscription} />
       )}
       )}
-      <PersonalSubscriptionStates subscription={subscription} />
+      <PersonalSubscriptionStates subscription={personalSubscription} />
       {recurlyLoadError && (
       {recurlyLoadError && (
         <div className="alert alert-warning" role="alert">
         <div className="alert alert-warning" role="alert">
           <strong>{t('payment_provider_unreachable_error')}</strong>
           <strong>{t('payment_provider_unreachable_error')}</strong>

+ 19 - 25
services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx

@@ -8,6 +8,8 @@ import { CancelSubscription } from './cancel-subscription'
 import { PendingPlanChange } from './pending-plan-change'
 import { PendingPlanChange } from './pending-plan-change'
 import { TrialEnding } from './trial-ending'
 import { TrialEnding } from './trial-ending'
 import { ChangePlan } from './change-plan'
 import { ChangePlan } from './change-plan'
+import { PendingAdditionalLicenses } from './pending-additional-licenses'
+import { ContactSupportToChangeGroupPlan } from './contact-support-to-change-group-plan'
 
 
 export function ActiveSubscription({
 export function ActiveSubscription({
   subscription,
   subscription,
@@ -38,40 +40,32 @@ export function ActiveSubscription({
         )}
         )}
         {!subscription.pendingPlan &&
         {!subscription.pendingPlan &&
           subscription.recurly.additionalLicenses > 0 && (
           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 />,
-                ]}
-              />
-            </>
-          )}{' '}
+            <PendingAdditionalLicenses
+              additionalLicenses={subscription.recurly.additionalLicenses}
+              totalLicenses={subscription.recurly.totalLicenses}
+            />
+          )}
         {!recurlyLoadError &&
         {!recurlyLoadError &&
           !subscription.groupPlan &&
           !subscription.groupPlan &&
           subscription.recurly.account.has_past_due_invoice._ !== 'true' && (
           subscription.recurly.account.has_past_due_invoice._ !== 'true' && (
-            <button
-              className="btn-inline-link"
-              onClick={() => setShowChangePersonalPlan(true)}
-            >
-              {t('change_plan')}
-            </button>
+            <>
+              {' '}
+              <button
+                className="btn-inline-link"
+                onClick={() => setShowChangePersonalPlan(true)}
+              >
+                {t('change_plan')}
+              </button>
+            </>
           )}
           )}
       </p>
       </p>
-      {/* && personalSubscription.pendingPlan.name != personalSubscription.plan.name */}
       {subscription.pendingPlan &&
       {subscription.pendingPlan &&
         subscription.pendingPlan.name !== subscription.plan.name && (
         subscription.pendingPlan.name !== subscription.plan.name && (
           <p>{t('want_change_to_apply_before_plan_end')}</p>
           <p>{t('want_change_to_apply_before_plan_end')}</p>
         )}
         )}
-      {/* TODO: groupPlan */}
+      {(!subscription.pendingPlan ||
+        subscription.pendingPlan.name === subscription.plan.name) &&
+        subscription.plan.groupPlan && <ContactSupportToChangeGroupPlan />}
       {subscription.recurly.trial_ends_at &&
       {subscription.recurly.trial_ends_at &&
         subscription.recurly.trialEndsAtFormatted && (
         subscription.recurly.trialEndsAtFormatted && (
           <TrialEnding
           <TrialEnding

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

@@ -1,18 +1,117 @@
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
+import { Plan } from '../../../../../../../../types/subscription/plan'
 import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
 import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
 
 
+function ChangeToPlanButton({ plan }: { plan: Plan }) {
+  const { t } = useTranslation()
+  // for when the user selected to change a plan, but the plan change is still pending
+  return (
+    <form>
+      {/* todo: ng-model="plan_code" */}
+      <input type="hidden" name="plan_code" value={plan.planCode} />
+      {/* todo: handle submit changePlan */}
+      <input
+        type="submit"
+        value={t('change_to_this_plan')}
+        className="btn btn-primary"
+      />
+    </form>
+  )
+}
+
+function KeepCurrentPlanButton({ plan }: { plan: Plan }) {
+  const { t } = useTranslation()
+  // for when the user selected to change a plan, but the plan change is still pending
+  return (
+    <form>
+      {/* todo: ng-model="plan_code" */}
+      <input type="hidden" name="plan_code" value={plan.planCode} />
+      {/* todo: handle submit cancelPendingPlanChange */}
+      <input
+        type="submit"
+        value={t('keep_current_plan')}
+        className="btn btn-primary"
+      />
+    </form>
+  )
+}
+
+function ChangePlanButton({ plan }: { plan: Plan }) {
+  const { t } = useTranslation()
+  const { personalSubscription } = useSubscriptionDashboardContext()
+  const isCurrentPlanForUser =
+    personalSubscription?.planCode &&
+    plan.planCode === personalSubscription.planCode.split('_')[0]
+
+  if (isCurrentPlanForUser && personalSubscription.pendingPlan) {
+    return <KeepCurrentPlanButton plan={plan} />
+  } else if (isCurrentPlanForUser && !personalSubscription.pendingPlan) {
+    return (
+      <button className="btn btn-secondary disabled">{t('your_plan')}</button>
+    )
+  } else if (
+    personalSubscription?.pendingPlan?.planCode?.split('_')[0] === plan.planCode
+  ) {
+    return (
+      <button className="btn btn-secondary disabled">
+        {t('your_new_plan')}
+      </button>
+    )
+  } else {
+    return <ChangeToPlanButton plan={plan} />
+  }
+}
+
+function PlansRow({ plan }: { plan: Plan }) {
+  const { t } = useTranslation()
+
+  return (
+    <tr>
+      <td>
+        <strong>{plan.name}</strong>
+      </td>
+      <td>
+        {/* todo: {{ displayPrice }} */}/ {plan.annual ? t('year') : t('month')}
+      </td>
+      <td>
+        <ChangePlanButton plan={plan} />
+      </td>
+    </tr>
+  )
+}
+
+function PlansRows({ plans }: { plans: Array<Plan> }) {
+  return (
+    <>
+      {plans &&
+        plans.map(plan => (
+          <PlansRow key={`plans-row-${plan.planCode}`} plan={plan} />
+        ))}
+    </>
+  )
+}
+
 export function ChangePlan() {
 export function ChangePlan() {
   const { t } = useTranslation()
   const { t } = useTranslation()
-  const { showChangePersonalPlan } = useSubscriptionDashboardContext()
+  const { plans, showChangePersonalPlan } = useSubscriptionDashboardContext()
 
 
-  if (!showChangePersonalPlan) return null
+  if (!showChangePersonalPlan || !plans) return null
 
 
   return (
   return (
     <>
     <>
       <h2>{t('change_plan')}</h2>
       <h2>{t('change_plan')}</h2>
-      <p>
-        <strong>TODO: change subscription placeholder</strong>
-      </p>
+      <table className="table">
+        <thead>
+          <tr>
+            <th>{t('name')}</th>
+            <th>{t('price')}</th>
+            <th />
+          </tr>
+        </thead>
+        <tbody>
+          <PlansRows plans={plans} />
+        </tbody>
+      </table>
     </>
     </>
   )
   )
 }
 }

+ 15 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/contact-support-to-change-group-plan.tsx

@@ -0,0 +1,15 @@
+import { Trans } from 'react-i18next'
+
+export function ContactSupportToChangeGroupPlan() {
+  return (
+    <p>
+      <Trans
+        i18nKey="contact_support_to_change_group_subscription"
+        components={[
+          // eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
+          <a href="/contact" />,
+        ]}
+      />
+    </p>
+  )
+}

+ 28 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/pending-additional-licenses.tsx

@@ -0,0 +1,28 @@
+import { Trans } from 'react-i18next'
+
+export function PendingAdditionalLicenses({
+  additionalLicenses,
+  totalLicenses,
+}: {
+  additionalLicenses: number
+  totalLicenses: number
+}) {
+  return (
+    <>
+      {' '}
+      <Trans
+        i18nKey="additional_licenses"
+        values={{
+          additionalLicenses,
+          totalLicenses,
+        }}
+        components={[
+          // eslint-disable-next-line react/jsx-key
+          <strong />,
+          // eslint-disable-next-line react/jsx-key
+          <strong />,
+        ]}
+      />
+    </>
+  )
+}

+ 5 - 14
services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx

@@ -1,20 +1,13 @@
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
-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 PersonalSubscription from './personal-subscription'
 import PersonalSubscription from './personal-subscription'
 import ManagedGroupSubscriptions from './managed-group-subscriptions'
 import ManagedGroupSubscriptions from './managed-group-subscriptions'
+import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
 
 
 function SubscriptionDashboard() {
 function SubscriptionDashboard() {
   const { t } = useTranslation()
   const { t } = useTranslation()
-  const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence')
-  const subscription = getMeta('ol-subscription')
-  const managedGroupSubscriptions = getMeta('ol-managedGroupSubscriptions')
-
-  const hasDisplayedSubscription =
-    institutionMemberships?.length > 0 ||
-    subscription ||
-    managedGroupSubscriptions?.length > 0
+  const { hasDisplayedSubscription } = useSubscriptionDashboardContext()
 
 
   return (
   return (
     <div className="container">
     <div className="container">
@@ -25,11 +18,9 @@ function SubscriptionDashboard() {
               <h1>{t('your_subscription')}</h1>
               <h1>{t('your_subscription')}</h1>
             </div>
             </div>
 
 
-            <PersonalSubscription subscription={subscription} />
-            <ManagedGroupSubscriptions
-              subscriptions={managedGroupSubscriptions}
-            />
-            <InstitutionMemberships memberships={institutionMemberships} />
+            <PersonalSubscription />
+            <ManagedGroupSubscriptions />
+            <InstitutionMemberships />
             {!hasDisplayedSubscription && <FreePlan />}
             {!hasDisplayedSubscription && <FreePlan />}
           </div>
           </div>
         </div>
         </div>

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

@@ -1,6 +1,18 @@
 import { createContext, ReactNode, useContext, useMemo, useState } from 'react'
 import { createContext, ReactNode, useContext, useMemo, useState } from 'react'
+import {
+  ManagedGroupSubscription,
+  Subscription,
+} from '../../../../../types/subscription/dashboard/subscription'
+import { Plan } from '../../../../../types/subscription/plan'
+import { Institution } from '../../../../../types/institution'
+import getMeta from '../../../utils/meta'
 
 
 type SubscriptionDashboardContextValue = {
 type SubscriptionDashboardContextValue = {
+  hasDisplayedSubscription: boolean
+  institutionMemberships?: Array<Institution>
+  managedGroupSubscriptions: Array<ManagedGroupSubscription>
+  personalSubscription?: Subscription
+  plans: Array<Plan>
   recurlyLoadError: boolean
   recurlyLoadError: boolean
   setRecurlyLoadError: React.Dispatch<React.SetStateAction<boolean>>
   setRecurlyLoadError: React.Dispatch<React.SetStateAction<boolean>>
   showCancellation: boolean
   showCancellation: boolean
@@ -22,8 +34,23 @@ export function SubscriptionDashboardProvider({
   const [showCancellation, setShowCancellation] = useState(false)
   const [showCancellation, setShowCancellation] = useState(false)
   const [showChangePersonalPlan, setShowChangePersonalPlan] = useState(false)
   const [showChangePersonalPlan, setShowChangePersonalPlan] = useState(false)
 
 
+  const plans = getMeta('ol-plans')
+  const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence')
+  const personalSubscription = getMeta('ol-subscription')
+  const managedGroupSubscriptions = getMeta('ol-managedGroupSubscriptions')
+
+  const hasDisplayedSubscription =
+    institutionMemberships?.length > 0 ||
+    personalSubscription ||
+    managedGroupSubscriptions
+
   const value = useMemo<SubscriptionDashboardContextValue>(
   const value = useMemo<SubscriptionDashboardContextValue>(
     () => ({
     () => ({
+      hasDisplayedSubscription,
+      institutionMemberships,
+      managedGroupSubscriptions,
+      personalSubscription,
+      plans,
       recurlyLoadError,
       recurlyLoadError,
       setRecurlyLoadError,
       setRecurlyLoadError,
       showCancellation,
       showCancellation,
@@ -32,6 +59,11 @@ export function SubscriptionDashboardProvider({
       setShowChangePersonalPlan,
       setShowChangePersonalPlan,
     }),
     }),
     [
     [
+      hasDisplayedSubscription,
+      institutionMemberships,
+      managedGroupSubscriptions,
+      personalSubscription,
+      plans,
       recurlyLoadError,
       recurlyLoadError,
       setRecurlyLoadError,
       setRecurlyLoadError,
       showCancellation,
       showCancellation,

+ 26 - 3
services/web/test/frontend/features/subscription/components/dashboard/institution-memberships.test.tsx

@@ -1,6 +1,7 @@
 import { expect } from 'chai'
 import { expect } from 'chai'
 import { render, screen } from '@testing-library/react'
 import { render, screen } from '@testing-library/react'
 import InstitutionMemberships from '../../../../../../frontend/js/features/subscription/components/dashboard/institution-memberships'
 import InstitutionMemberships from '../../../../../../frontend/js/features/subscription/components/dashboard/institution-memberships'
+import { SubscriptionDashboardProvider } from '../../../../../../frontend/js/features/subscription/context/subscription-dashboard-context'
 
 
 const memberships = [
 const memberships = [
   {
   {
@@ -35,7 +36,16 @@ describe('<InstitutionMemberships />', function () {
   })
   })
 
 
   it('renders all insitutions with license', function () {
   it('renders all insitutions with license', function () {
-    render(<InstitutionMemberships memberships={memberships} />)
+    window.metaAttributesCache.set(
+      'ol-currentInstitutionsWithLicence',
+      memberships
+    )
+
+    render(
+      <SubscriptionDashboardProvider>
+        <InstitutionMemberships />
+      </SubscriptionDashboardProvider>
+    )
 
 
     const elements = screen.getAllByText('You are on our', {
     const elements = screen.getAllByText('You are on our', {
       exact: false,
       exact: false,
@@ -50,14 +60,27 @@ describe('<InstitutionMemberships />', function () {
   })
   })
 
 
   it('renders error message when failed to check commons licenses', function () {
   it('renders error message when failed to check commons licenses', function () {
-    render(<InstitutionMemberships memberships={undefined} />)
+    render(
+      <SubscriptionDashboardProvider>
+        <InstitutionMemberships />
+      </SubscriptionDashboardProvider>
+    )
     screen.getByText(
     screen.getByText(
       '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 () {
   it('renders the "Get the most out of your" subscription text when a user has a subscription', function () {
-    render(<InstitutionMemberships memberships={memberships} />)
+    window.metaAttributesCache.set(
+      'ol-currentInstitutionsWithLicence',
+      memberships
+    )
+
+    render(
+      <SubscriptionDashboardProvider>
+        <InstitutionMemberships />
+      </SubscriptionDashboardProvider>
+    )
     screen.getByText('Get the most out of your', {
     screen.getByText('Get the most out of your', {
       exact: false,
       exact: false,
     })
     })

+ 15 - 5
services/web/test/frontend/features/subscription/components/dashboard/managed-group-subscriptions.test.tsx

@@ -1,12 +1,12 @@
 import { expect } from 'chai'
 import { expect } from 'chai'
 import { render, screen } from '@testing-library/react'
 import { render, screen } from '@testing-library/react'
-import ManagedGroupSubscriptions, {
-  ManagedGroupSubscription,
-} from '../../../../../../frontend/js/features/subscription/components/dashboard/managed-group-subscriptions'
 import {
 import {
   groupActiveSubscription,
   groupActiveSubscription,
   groupActiveSubscriptionWithPendingLicenseChange,
   groupActiveSubscriptionWithPendingLicenseChange,
 } from '../../fixtures/subscriptions'
 } from '../../fixtures/subscriptions'
+import ManagedGroupSubscriptions from '../../../../../../frontend/js/features/subscription/components/dashboard/managed-group-subscriptions'
+import { ManagedGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription'
+import { SubscriptionDashboardProvider } from '../../../../../../frontend/js/features/subscription/context/subscription-dashboard-context'
 
 
 const managedGroupSubscriptions: ManagedGroupSubscription[] = [
 const managedGroupSubscriptions: ManagedGroupSubscription[] = [
   {
   {
@@ -39,8 +39,14 @@ describe('<ManagedGroupSubscriptions />', function () {
   })
   })
 
 
   it('renders all managed group subscriptions', function () {
   it('renders all managed group subscriptions', function () {
+    window.metaAttributesCache.set(
+      'ol-managedGroupSubscriptions',
+      managedGroupSubscriptions
+    )
     render(
     render(
-      <ManagedGroupSubscriptions subscriptions={managedGroupSubscriptions} />
+      <SubscriptionDashboardProvider>
+        <ManagedGroupSubscriptions />
+      </SubscriptionDashboardProvider>
     )
     )
 
 
     const elements = screen.getAllByText('You are a', {
     const elements = screen.getAllByText('You are a', {
@@ -85,7 +91,11 @@ describe('<ManagedGroupSubscriptions />', function () {
   })
   })
 
 
   it('renders nothing when there are no group memberships', function () {
   it('renders nothing when there are no group memberships', function () {
-    render(<ManagedGroupSubscriptions subscriptions={undefined} />)
+    render(
+      <SubscriptionDashboardProvider>
+        <ManagedGroupSubscriptions />
+      </SubscriptionDashboardProvider>
+    )
     const elements = screen.queryAllByText('You are a', {
     const elements = screen.queryAllByText('You are a', {
       exact: false,
       exact: false,
     })
     })

+ 30 - 8
services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx

@@ -25,7 +25,7 @@ describe('<PersonalSubscription />', function () {
     it('returns empty container', function () {
     it('returns empty container', function () {
       const { container } = render(
       const { container } = render(
         <SubscriptionDashboardProvider>
         <SubscriptionDashboardProvider>
-          <PersonalSubscription subscription={undefined} />
+          <PersonalSubscription />
         </SubscriptionDashboardProvider>
         </SubscriptionDashboardProvider>
       )
       )
       expect(container.firstChild).to.be.null
       expect(container.firstChild).to.be.null
@@ -34,9 +34,13 @@ describe('<PersonalSubscription />', function () {
 
 
   describe('subscription states  ', function () {
   describe('subscription states  ', function () {
     it('renders the active dash', function () {
     it('renders the active dash', function () {
+      window.metaAttributesCache.set(
+        'ol-subscription',
+        annualActiveSubscription
+      )
       render(
       render(
         <SubscriptionDashboardProvider>
         <SubscriptionDashboardProvider>
-          <PersonalSubscription subscription={annualActiveSubscription} />
+          <PersonalSubscription />
         </SubscriptionDashboardProvider>
         </SubscriptionDashboardProvider>
       )
       )
 
 
@@ -44,9 +48,10 @@ describe('<PersonalSubscription />', function () {
     })
     })
 
 
     it('renders the canceled dash', function () {
     it('renders the canceled dash', function () {
+      window.metaAttributesCache.set('ol-subscription', canceledSubscription)
       render(
       render(
         <SubscriptionDashboardProvider>
         <SubscriptionDashboardProvider>
-          <PersonalSubscription subscription={canceledSubscription} />
+          <PersonalSubscription />
         </SubscriptionDashboardProvider>
         </SubscriptionDashboardProvider>
       )
       )
       screen.getByText(
       screen.getByText(
@@ -69,9 +74,13 @@ describe('<PersonalSubscription />', function () {
     })
     })
 
 
     it('renders the expired dash', function () {
     it('renders the expired dash', function () {
+      window.metaAttributesCache.set(
+        'ol-subscription',
+        pastDueExpiredSubscription
+      )
       render(
       render(
         <SubscriptionDashboardProvider>
         <SubscriptionDashboardProvider>
-          <PersonalSubscription subscription={pastDueExpiredSubscription} />
+          <PersonalSubscription />
         </SubscriptionDashboardProvider>
         </SubscriptionDashboardProvider>
       )
       )
       screen.getByText('Your subscription has expired.')
       screen.getByText('Your subscription has expired.')
@@ -83,9 +92,10 @@ describe('<PersonalSubscription />', function () {
         JSON.parse(JSON.stringify(annualActiveSubscription))
         JSON.parse(JSON.stringify(annualActiveSubscription))
       )
       )
       withStateDeleted.recurly.state = undefined
       withStateDeleted.recurly.state = undefined
+      window.metaAttributesCache.set('ol-subscription', withStateDeleted)
       render(
       render(
         <SubscriptionDashboardProvider>
         <SubscriptionDashboardProvider>
-          <PersonalSubscription subscription={withStateDeleted} />
+          <PersonalSubscription />
         </SubscriptionDashboardProvider>
         </SubscriptionDashboardProvider>
       )
       )
       screen.getByText(
       screen.getByText(
@@ -96,9 +106,13 @@ describe('<PersonalSubscription />', function () {
 
 
   describe('past due subscription', function () {
   describe('past due subscription', function () {
     it('renders error alert', function () {
     it('renders error alert', function () {
+      window.metaAttributesCache.set(
+        'ol-subscription',
+        pastDueExpiredSubscription
+      )
       render(
       render(
         <SubscriptionDashboardProvider>
         <SubscriptionDashboardProvider>
-          <PersonalSubscription subscription={pastDueExpiredSubscription} />
+          <PersonalSubscription />
         </SubscriptionDashboardProvider>
         </SubscriptionDashboardProvider>
       )
       )
       screen.getByRole('alert')
       screen.getByRole('alert')
@@ -120,9 +134,13 @@ describe('<PersonalSubscription />', function () {
     it('shows an alert and hides "Change plan" option when Recurly did not load', function () {
     it('shows an alert and hides "Change plan" option when Recurly did not load', function () {
       // @ts-ignore
       // @ts-ignore
       delete window.recurly
       delete window.recurly
+      window.metaAttributesCache.set(
+        'ol-subscription',
+        annualActiveSubscription
+      )
       render(
       render(
         <SubscriptionDashboardProvider>
         <SubscriptionDashboardProvider>
-          <PersonalSubscription subscription={annualActiveSubscription} />
+          <PersonalSubscription />
         </SubscriptionDashboardProvider>
         </SubscriptionDashboardProvider>
       )
       )
 
 
@@ -133,9 +151,13 @@ describe('<PersonalSubscription />', function () {
     })
     })
 
 
     it('should not show an alert and should show "Change plan" option when Recurly did load', function () {
     it('should not show an alert and should show "Change plan" option when Recurly did load', function () {
+      window.metaAttributesCache.set(
+        'ol-subscription',
+        annualActiveSubscription
+      )
       render(
       render(
         <SubscriptionDashboardProvider>
         <SubscriptionDashboardProvider>
-          <PersonalSubscription subscription={annualActiveSubscription} />
+          <PersonalSubscription />
         </SubscriptionDashboardProvider>
         </SubscriptionDashboardProvider>
       )
       )
 
 

+ 36 - 14
services/web/test/frontend/features/subscription/components/dashboard/states/active.test.tsx

@@ -12,12 +12,14 @@ import {
   trialSubscription,
   trialSubscription,
 } from '../../../fixtures/subscriptions'
 } from '../../../fixtures/subscriptions'
 import sinon from 'sinon'
 import sinon from 'sinon'
+import { plans } from '../../../fixtures/plans'
 
 
 describe('<ActiveSubscription />', function () {
 describe('<ActiveSubscription />', function () {
   let sendMBSpy: sinon.SinonSpy
   let sendMBSpy: sinon.SinonSpy
 
 
   beforeEach(function () {
   beforeEach(function () {
     window.metaAttributesCache = new Map()
     window.metaAttributesCache = new Map()
+    window.metaAttributesCache.set('ol-plans', plans)
     sendMBSpy = sinon.spy(eventTracking, 'sendMB')
     sendMBSpy = sinon.spy(eventTracking, 'sendMB')
   })
   })
 
 
@@ -77,11 +79,13 @@ describe('<ActiveSubscription />', function () {
     const button = screen.getByRole('button', { name: 'Change plan' })
     const button = screen.getByRole('button', { name: 'Change plan' })
     fireEvent.click(button)
     fireEvent.click(button)
 
 
-    // confirm main dash UI UI still shown
-    expectedInActiveSubscription(annualActiveSubscription)
+    // confirm main dash UI still shown
+    screen.getByText('You are currently subscribed to the', { exact: false })
 
 
-    // TODO: add change plan UI
-    screen.getByText('change subscription placeholder', { exact: false })
+    screen.getByRole('heading', { name: 'Change plan' })
+    expect(
+      screen.getAllByRole('button', { name: 'Change to this plan' }).length > 0
+    ).to.be.true
   })
   })
 
 
   it('notes when user is changing plan at end of current plan term', function () {
   it('notes when user is changing plan at end of current plan term', function () {
@@ -103,17 +107,10 @@ describe('<ActiveSubscription />', function () {
     screen.getByText(
     screen.getByText(
       'If you wish this change to apply before the end of your current billing period, please contact us.'
       'If you wish this change to apply before the end of your current billing period, please contact us.'
     )
     )
-  })
-
-  it('does not show "Change plan" option for group plans', function () {
-    render(
-      <SubscriptionDashboardProvider>
-        <ActiveSubscription subscription={groupActiveSubscription} />
-      </SubscriptionDashboardProvider>
-    )
 
 
-    const changePlan = screen.queryByRole('button', { name: 'Change plan' })
-    expect(changePlan).to.be.null
+    expect(screen.queryByRole('link', { name: 'contact support' })).to.be.null
+    expect(screen.queryByText('if you wish to change your group subscription.'))
+      .to.be.null
   })
   })
 
 
   it('does not show "Change plan" option when past due', function () {
   it('does not show "Change plan" option when past due', function () {
@@ -234,4 +231,29 @@ describe('<ActiveSubscription />', function () {
 
 
     screen.getByText('We’d love you to stay')
     screen.getByText('We’d love you to stay')
   })
   })
+
+  describe('group plans', function () {
+    it('does not show "Change plan" option for group plans', function () {
+      render(
+        <SubscriptionDashboardProvider>
+          <ActiveSubscription subscription={groupActiveSubscription} />
+        </SubscriptionDashboardProvider>
+      )
+
+      const changePlan = screen.queryByRole('button', { name: 'Change plan' })
+      expect(changePlan).to.be.null
+    })
+
+    it('shows contact support message for group plan change requests', function () {
+      render(
+        <SubscriptionDashboardProvider>
+          <ActiveSubscription subscription={groupActiveSubscription} />
+        </SubscriptionDashboardProvider>
+      )
+      screen.getByRole('link', { name: 'contact support' })
+      screen.getByText('if you wish to change your group subscription.', {
+        exact: false,
+      })
+    })
+  })
 })
 })

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

@@ -0,0 +1,71 @@
+import { expect } from 'chai'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { SubscriptionDashboardProvider } from '../../../../../../../../frontend/js/features/subscription/context/subscription-dashboard-context'
+import { ChangePlan } from '../../../../../../../../frontend/js/features/subscription/components/dashboard/states/active/change-plan'
+import { plans } from '../../../../fixtures/plans'
+import {
+  annualActiveSubscription,
+  pendingSubscriptionChange,
+} from '../../../../fixtures/subscriptions'
+import { ActiveSubscription } from '../../../../../../../../frontend/js/features/subscription/components/dashboard/states/active/active'
+
+describe('<ChangePlan />', function () {
+  beforeEach(function () {
+    window.metaAttributesCache = new Map()
+    window.metaAttributesCache.set('ol-plans', plans)
+  })
+
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  it('does not render the UI when showChangePersonalPlan is false', function () {
+    window.metaAttributesCache.delete('ol-plans')
+    const { container } = render(
+      <SubscriptionDashboardProvider>
+        <ChangePlan />
+      </SubscriptionDashboardProvider>
+    )
+
+    expect(container.firstChild).to.be.null
+  })
+
+  it('renders the table of plans', function () {
+    window.metaAttributesCache.set('ol-subscription', annualActiveSubscription)
+    render(
+      <SubscriptionDashboardProvider>
+        <ActiveSubscription subscription={annualActiveSubscription} />
+      </SubscriptionDashboardProvider>
+    )
+
+    const button = screen.getByRole('button', { name: 'Change plan' })
+    fireEvent.click(button)
+
+    const changeToPlanButtons = screen.queryAllByRole('button', {
+      name: 'Change to this plan',
+    })
+    expect(changeToPlanButtons.length).to.equal(plans.length - 1)
+    screen.getByRole('button', { name: 'Your plan' })
+
+    const annualPlans = plans.filter(plan => plan.annual)
+    expect(screen.getAllByText('/ year').length).to.equal(annualPlans.length)
+    expect(screen.getAllByText('/ month').length).to.equal(
+      plans.length - annualPlans.length
+    )
+  })
+
+  it('renders "Your new plan" and "Keep current plan" when there is a pending plan change', function () {
+    window.metaAttributesCache.set('ol-subscription', pendingSubscriptionChange)
+    render(
+      <SubscriptionDashboardProvider>
+        <ActiveSubscription subscription={pendingSubscriptionChange} />
+      </SubscriptionDashboardProvider>
+    )
+
+    const button = screen.getByRole('button', { name: 'Change plan' })
+    fireEvent.click(button)
+
+    screen.getByText('Your new plan')
+    screen.getByRole('button', { name: 'Keep my current plan' })
+  })
+})

+ 215 - 0
services/web/test/frontend/features/subscription/fixtures/plans.tsx

@@ -0,0 +1,215 @@
+import { Plan } from '../../../../../types/subscription/plan'
+
+const features = {
+  student: {
+    collaborators: 6,
+    dropbox: true,
+    versioning: true,
+    github: true,
+    templates: true,
+    references: true,
+    referencesSearch: true,
+    gitBridge: true,
+    zotero: true,
+    mendeley: true,
+    compileTimeout: 240,
+    compileGroup: 'priority',
+    trackChanges: true,
+    symbolPalette: true,
+  },
+  personal: {
+    collaborators: 1,
+    dropbox: true,
+    versioning: true,
+    github: true,
+    gitBridge: true,
+    templates: true,
+    references: true,
+    referencesSearch: true,
+    zotero: true,
+    mendeley: true,
+    compileTimeout: 240,
+    compileGroup: 'priority',
+    trackChanges: false,
+    symbolPalette: true,
+  },
+  collaborator: {
+    collaborators: 10,
+    dropbox: true,
+    versioning: true,
+    github: true,
+    templates: true,
+    references: true,
+    referencesSearch: true,
+    zotero: true,
+    gitBridge: true,
+    mendeley: true,
+    compileTimeout: 240,
+    compileGroup: 'priority',
+    trackChanges: true,
+    symbolPalette: true,
+  },
+  professional: {
+    collaborators: -1,
+    dropbox: true,
+    versioning: true,
+    github: true,
+    templates: true,
+    references: true,
+    referencesSearch: true,
+    zotero: true,
+    gitBridge: true,
+    mendeley: true,
+    compileTimeout: 240,
+    compileGroup: 'priority',
+    trackChanges: true,
+    symbolPalette: true,
+  },
+}
+
+const studentAccounts: Array<Plan> = [
+  {
+    planCode: 'student',
+    name: 'Student',
+    price_in_cents: 1000,
+    features: features.student,
+    featureDescription: [],
+  },
+  {
+    planCode: 'student-annual',
+    name: 'Student Annual',
+    price_in_cents: 9900,
+    annual: true,
+    features: features.student,
+    featureDescription: [],
+  },
+  {
+    planCode: 'student_free_trial',
+    name: 'Student',
+    price_in_cents: 800,
+    features: features.student,
+    hideFromUsers: true,
+    featureDescription: [],
+  },
+  {
+    planCode: 'student_free_trial_7_days',
+    name: 'Student',
+    price_in_cents: 1000,
+    features: features.student,
+    hideFromUsers: true,
+    featureDescription: [],
+  },
+]
+
+const individualMonthlyPlans: Array<Plan> = [
+  {
+    planCode: 'paid-personal',
+    name: 'Personal',
+    price_in_cents: 1500,
+    features: features.personal,
+    featureDescription: [],
+  },
+  {
+    planCode: 'paid-personal_free_trial_7_days',
+    name: 'Personal (Hidden)',
+    price_in_cents: 1500,
+    features: features.personal,
+    featureDescription: [],
+    hideFromUsers: true,
+  },
+  {
+    planCode: 'collaborator',
+    name: 'Standard (Collaborator)',
+    price_in_cents: 2300,
+    features: features.collaborator,
+    featureDescription: [],
+  },
+  {
+    planCode: 'professional',
+    name: 'Professional',
+    price_in_cents: 4500,
+    features: features.professional,
+    featureDescription: [],
+  },
+  {
+    planCode: 'collaborator_free_trial',
+    name: 'Standard (Collaborator) (Hidden)',
+    price_in_cents: 1900,
+    features: features.collaborator,
+    hideFromUsers: true,
+    featureDescription: [],
+  },
+  {
+    planCode: 'collaborator_free_trial_14_days',
+    name: 'Standard (Collaborator) (Hidden)',
+    price_in_cents: 1900,
+    features: features.collaborator,
+    hideFromUsers: true,
+    featureDescription: [],
+  },
+  {
+    planCode: 'collaborator_free_trial_7_days',
+    name: 'Standard (Collaborator) (Hidden)',
+    price_in_cents: 2300,
+    features: features.collaborator,
+    hideFromUsers: true,
+    featureDescription: [],
+  },
+  {
+    planCode: 'collaborator-annual_free_trial',
+    name: 'Standard (Collaborator) Annual (Hidden)',
+    price_in_cents: 18000,
+    features: features.collaborator,
+    hideFromUsers: true,
+    featureDescription: [],
+  },
+  {
+    planCode: 'professional_free_trial',
+    name: 'Professional (Hidden)',
+    price_in_cents: 3000,
+    features: features.professional,
+    hideFromUsers: true,
+    featureDescription: [],
+  },
+  {
+    planCode: 'professional_free_trial_7_days',
+    name: 'Professional (Hidden)',
+    price_in_cents: 4500,
+    features: features.professional,
+    hideFromUsers: true,
+    featureDescription: [],
+  },
+]
+
+const individualAnnualPlans: Array<Plan> = [
+  {
+    planCode: 'paid-personal-annual',
+    name: 'Personal Annual',
+    price_in_cents: 13900,
+    annual: true,
+    features: features.personal,
+    featureDescription: [],
+  },
+  {
+    planCode: 'collaborator-annual',
+    name: 'Standard (Collaborator) Annual',
+    price_in_cents: 21900,
+    annual: true,
+    features: features.collaborator,
+    featureDescription: [],
+  },
+  {
+    planCode: 'professional-annual',
+    name: 'Professional Annual',
+    price_in_cents: 42900,
+    annual: true,
+    features: features.professional,
+    featureDescription: [],
+  },
+]
+
+export const plans = [
+  ...studentAccounts,
+  ...individualMonthlyPlans,
+  ...individualAnnualPlans,
+]

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

@@ -1,5 +1,6 @@
 import { Nullable } from '../../utils'
 import { Nullable } from '../../utils'
 import { Plan } from '../plan'
 import { Plan } from '../plan'
+import { User } from '../../../types/user'
 
 
 type SubscriptionState = 'active' | 'canceled' | 'expired'
 type SubscriptionState = 'active' | 'canceled' | 'expired'
 
 
@@ -54,3 +55,9 @@ export type Subscription = {
 export type GroupSubscription = Subscription & {
 export type GroupSubscription = Subscription & {
   teamName: string
   teamName: string
 }
 }
+
+export type ManagedGroupSubscription = Omit<GroupSubscription, 'admin_id'> & {
+  userIsGroupMember: boolean
+  planLevelName: string
+  admin_id: User
+}