Przeglądaj źródła

Merge pull request #11773 from overleaf/jel-subscription-dash-change-to-group

[web] Begin change to group plan via React subscription dash

GitOrigin-RevId: 3f0f2820ab18ecc8337746282295302d7951c56f
Jessica Lawshe 3 lat temu
rodzic
commit
b2a10260be
17 zmienionych plików z 529 dodań i 24 usunięć
  1. 12 2
      services/web/app/src/Features/Subscription/SubscriptionController.js
  2. 0 1
      services/web/app/views/subscriptions/dashboard-react.pug
  3. 18 0
      services/web/frontend/extracted-translations.json
  4. 4 2
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-plan.tsx
  5. 12 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-to-group-plan.tsx
  6. 270 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx
  7. 7 6
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx
  8. 6 5
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/keep-current-plan-modal.tsx
  9. 39 5
      services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx
  10. 10 0
      services/web/frontend/stylesheets/components/forms.less
  11. 4 0
      services/web/frontend/stylesheets/components/lists.less
  12. 15 0
      services/web/locales/en.json
  13. 101 1
      services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx
  14. 15 0
      services/web/test/frontend/features/subscription/fixtures/plans.tsx
  15. 5 1
      services/web/test/frontend/features/subscription/helpers/render-active-subscription.tsx
  16. 7 0
      services/web/types/subscription/dashboard/group-plans.ts
  17. 4 0
      services/web/types/subscription/dashboard/modal-ids.ts

+ 12 - 2
services/web/app/src/Features/Subscription/SubscriptionController.js

@@ -289,6 +289,15 @@ async function userSubscriptionPage(req, res) {
   }
 }
 
+function formatGroupPlansDataForDash() {
+  return {
+    plans: [...groupPlanModalOptions.plan_codes],
+    sizes: [...groupPlanModalOptions.sizes],
+    usages: [...groupPlanModalOptions.usages],
+    priceByUsageTypeAndSize: JSON.parse(JSON.stringify(GroupPlansData)),
+  }
+}
+
 /**
  * @param {import("express").Request} req
  * @param {import("express").Response} res
@@ -327,11 +336,12 @@ async function _userSubscriptionReactPage(req, res) {
 
   const cancelButtonNewCopy = cancelButtonAssignment?.variant === 'new-copy'
 
+  const groupPlansDataForDash = formatGroupPlansDataForDash()
+
   const data = {
     title: 'your_subscription',
     plans: plansData?.plans,
     planCodesChangingAtTermEnd: plansData?.planCodesChangingAtTermEnd,
-    groupPlans: GroupPlansData,
     user,
     hasSubscription,
     fromPlansPage,
@@ -342,8 +352,8 @@ async function _userSubscriptionReactPage(req, res) {
     managedPublishers,
     v1SubscriptionStatus,
     currentInstitutionsWithLicence,
-    groupPlanModalOptions,
     cancelButtonNewCopy,
+    groupPlans: groupPlansDataForDash,
   }
   res.render('subscriptions/dashboard-react', data)
 }

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

@@ -17,7 +17,6 @@ block append meta
 		meta(name="ol-subscription" data-type="json" content=personalSubscription)
 		meta(name="ol-recommendedCurrency" content=personalSubscription.recurly.currency)
 		meta(name="ol-groupPlans" data-type="json" content=groupPlans)
-		meta(name="ol-groupPlanModalOptions" data-type="json" content=groupPlanModalOptions)
 
 block content
 	main.content.content-alt#subscription-dashboard-root

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

@@ -32,6 +32,7 @@
   "additional_licenses": "",
   "address_line_1": "",
   "address_second_line_optional": "",
+  "all_premium_features": "",
   "all_premium_features_including": "",
   "all_projects": "",
   "also": "",
@@ -96,6 +97,7 @@
   "change_plan": "",
   "change_primary_email_address_instructions": "",
   "change_project_owner": "",
+  "change_to_group_plan": "",
   "change_to_this_plan": "",
   "chat": "",
   "chat_error": "",
@@ -153,6 +155,7 @@
   "creating": "",
   "current_password": "",
   "currently_subscribed_to_plan": "",
+  "customize_your_group_subscription": "",
   "date_and_owner": "",
   "delete": "",
   "delete_account": "",
@@ -205,6 +208,7 @@
   "dropbox_unlinked_premium_feature": "",
   "duplicate_file": "",
   "duplicate_projects": "",
+  "each_user_will_have_access_to": "",
   "easily_manage_your_project_files_everywhere": "",
   "edit": "",
   "edit_dictionary": "",
@@ -215,6 +219,8 @@
   "editor_and_pdf": "&",
   "editor_only_hide_pdf": "",
   "editor_theme": "",
+  "educational_discount_for_groups_of_x_or_more": "",
+  "educational_percent_discount_applied": "",
   "email": "",
   "email_or_password_wrong_try_again": "",
   "emails_and_affiliations_explanation": "",
@@ -405,6 +411,7 @@
   "leave": "",
   "leave_projects": "",
   "let_us_know": "",
+  "license_for_educational_purposes": "",
   "limited_offer": "",
   "line_height": "",
   "link": "",
@@ -472,6 +479,7 @@
   "name": "",
   "navigate_log_source": "",
   "navigation": "",
+  "need_more_than_x_licenses": "",
   "need_to_add_new_primary_before_remove": "",
   "need_to_leave": "",
   "need_to_upgrade_for_more_collabs": "",
@@ -481,6 +489,7 @@
   "new_name": "",
   "new_password": "",
   "new_project": "",
+  "new_subscription_will_be_billed_immediately": "",
   "new_to_latex_look_at": "",
   "newsletter": "",
   "next_payment_of_x_collectected_on_y": "",
@@ -503,6 +512,7 @@
   "normally_x_price_per_year": "",
   "notification_project_invite_accepted_message": "",
   "notification_project_invite_message": "",
+  "number_of_users": "",
   "oauth_orcid_description": "",
   "of": "",
   "off": "",
@@ -539,6 +549,8 @@
   "pdf_viewer": "",
   "pdf_viewer_error": "",
   "pending_additional_licenses": "",
+  "percent_discount_for_groups": "",
+  "plan": "",
   "plan_tooltip": "",
   "please_change_primary_to_remove": "",
   "please_check_your_inbox": "",
@@ -547,6 +559,7 @@
   "please_compile_pdf_before_word_count": "",
   "please_confirm_email": "",
   "please_confirm_your_email_before_making_it_default": "",
+  "please_get_in_touch": "",
   "please_link_before_making_primary": "",
   "please_reconfirm_institutional_email": "",
   "please_reconfirm_your_affiliation_before_making_this_primary": "",
@@ -555,6 +568,7 @@
   "please_select_a_project": "",
   "please_select_an_output_file": "",
   "please_set_main_file": "",
+  "plus_more": "",
   "plus_upgraded_accounts_receive": "",
   "postal_code": "",
   "premium_feature": "",
@@ -611,6 +625,7 @@
   "recompile_pdf": "",
   "reconnect": "",
   "redirect_to_editor": "",
+  "reduce_costs_group_licenses": "",
   "reference_error_relink_hint": "",
   "reference_managers": "",
   "reference_search": "",
@@ -646,6 +661,7 @@
   "save_or_cancel-cancel": "",
   "save_or_cancel-or": "",
   "save_or_cancel-save": "",
+  "save_x_percent_or_more": "",
   "saved_bibtex_appended_to_galileo_bib": "",
   "saved_bibtex_to_new_galileo_bib": "",
   "saving": "",
@@ -881,6 +897,8 @@
   "x_price_for_first_month": "",
   "x_price_for_first_year": "",
   "x_price_for_y_months": "",
+  "x_price_per_user": "",
+  "x_price_per_year": "",
   "year": "",
   "you_are_a_manager_and_member_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
   "you_are_a_manager_of_commons_at_institution_x": "",

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

@@ -2,9 +2,10 @@ import { useTranslation } from 'react-i18next'
 import LoadingSpinner from '../../../../../../../shared/components/loading-spinner'
 import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
 import { ChangeToGroupPlan } from './change-to-group-plan'
-import { ConfirmChangePlanModal } from './confirm-change-plan-modal'
+import { ConfirmChangePlanModal } from './modals/confirm-change-plan-modal'
 import { IndividualPlansTable } from './individual-plans-table'
-import { KeepCurrentPlanModal } from './keep-current-plan-modal'
+import { KeepCurrentPlanModal } from './modals/keep-current-plan-modal'
+import { ChangeToGroupModal } from './modals/change-to-group-modal'
 
 export function ChangePlan() {
   const { t } = useTranslation()
@@ -32,6 +33,7 @@ export function ChangePlan() {
         <ChangeToGroupPlan />
         <ConfirmChangePlanModal />
         <KeepCurrentPlanModal />
+        <ChangeToGroupModal />
       </>
     )
   }

+ 12 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-to-group-plan.tsx

@@ -1,11 +1,22 @@
 import { useTranslation } from 'react-i18next'
+import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
 
 export function ChangeToGroupPlan() {
   const { t } = useTranslation()
+  const { handleOpenModal } = useSubscriptionDashboardContext()
+
+  const handleClick = () => {
+    handleOpenModal('change-to-group')
+  }
+
   return (
     <>
       <h2>{t('looking_multiple_licenses')}</h2>
-      {/* todo: if/else isValidCurrencyForUpgrade and modal */}
+      <p style={{ margin: 0 }}>{t('reduce_costs_group_licenses')}</p>
+      <br />
+      <button className="btn btn-primary" onClick={handleClick}>
+        {t('change_to_group_plan')}
+      </button>
     </>
   )
 }

+ 270 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx

@@ -0,0 +1,270 @@
+import { useEffect } from 'react'
+import { Modal } from 'react-bootstrap'
+import { useTranslation, Trans } from 'react-i18next'
+import { GroupPlans } from '../../../../../../../../../../types/subscription/dashboard/group-plans'
+import { Subscription } from '../../../../../../../../../../types/subscription/dashboard/subscription'
+import AccessibleModal from '../../../../../../../../shared/components/accessible-modal'
+import getMeta from '../../../../../../../../utils/meta'
+import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
+
+const educationalPercentDiscount = 40
+const groupSizeForEducationalDiscount = 10
+
+function GroupPlanCollaboratorCount({ planCode }: { planCode: string }) {
+  const { t } = useTranslation()
+
+  if (planCode === 'collaborator') {
+    return (
+      <>
+        <Trans
+          i18nKey="collabs_per_proj"
+          values={{
+            collabcount: 10,
+          }}
+        />
+      </>
+    )
+  } else if (planCode === 'professional') {
+    return <>{t('unlimited_collabs')}</>
+  }
+  return null
+}
+
+function EducationDiscountAppliedOrNot({ groupSize }: { groupSize: string }) {
+  const size = parseInt(groupSize)
+  if (size >= groupSizeForEducationalDiscount) {
+    return (
+      <p className="applied">
+        <Trans
+          i18nKey="educational_percent_discount_applied"
+          values={{ percent: educationalPercentDiscount }}
+        />
+      </p>
+    )
+  }
+
+  return (
+    <p className="ineligible">
+      <Trans
+        i18nKey="educational_discount_for_groups_of_x_or_more"
+        values={{ size: groupSizeForEducationalDiscount }}
+      />
+    </p>
+  )
+}
+
+function GroupPrice() {
+  const { t } = useTranslation()
+  return (
+    <>
+      <span aria-hidden>
+        X <span className="small">/ {t('year')}</span>
+      </span>
+      <span className="sr-only">
+        {/* TODO: price */}
+        <Trans i18nKey="x_price_per_year" values={{ price: '$X' }} />
+      </span>
+      <br />
+      <span className="circle-subtext">
+        {/* TODO: price */}
+        <Trans i18nKey="x_price_per_user" values={{ price: '$X' }} />
+      </span>
+    </>
+  )
+}
+
+export function ChangeToGroupModal() {
+  const modalId = 'change-to-group'
+  const { t } = useTranslation()
+  const {
+    groupPlanToChangeToCode,
+    groupPlanToChangeToSize,
+    groupPlanToChangeToUsage,
+    handleCloseModal,
+    modalIdShown,
+    setGroupPlanToChangeToCode,
+    setGroupPlanToChangeToSize,
+    setGroupPlanToChangeToUsage,
+  } = useSubscriptionDashboardContext()
+  const groupPlans: GroupPlans = getMeta('ol-groupPlans')
+  const personalSubscription: Subscription = getMeta('ol-subscription')
+
+  useEffect(() => {
+    const defaultPlanOption = personalSubscription.plan.planCode.includes(
+      'professional'
+    )
+      ? 'professional'
+      : 'collaborator'
+    setGroupPlanToChangeToCode(defaultPlanOption)
+  }, [personalSubscription, setGroupPlanToChangeToCode])
+
+  if (
+    modalIdShown !== modalId ||
+    !groupPlans ||
+    !groupPlans.plans ||
+    !groupPlans.sizes ||
+    !groupPlanToChangeToCode
+  )
+    return null
+
+  return (
+    <AccessibleModal
+      id={modalId}
+      show
+      animation
+      onHide={handleCloseModal}
+      backdrop="static"
+    >
+      <Modal.Header>
+        <button className="close" onClick={handleCloseModal}>
+          <span aria-hidden="true">×</span>
+          <span className="sr-only">{t('close')}</span>
+        </button>
+        <div className="modal-title">
+          <h2>{t('customize_your_group_subscription')}</h2>
+          <h3>
+            <Trans
+              i18nKey="save_x_percent_or_more"
+              values={{
+                percent: '30',
+              }}
+            />
+          </h3>
+        </div>
+      </Modal.Header>
+
+      <Modal.Body>
+        <div className="container-fluid plans group-subscription-modal">
+          <div className="row">
+            <div className="col-md-6 text-center">
+              <div className="circle circle-lg">
+                <GroupPrice />
+              </div>
+              <p>{t('each_user_will_have_access_to')}:</p>
+              <ul className="list-unstyled">
+                <li className="list-item-with-margin-bottom">
+                  <strong>
+                    <GroupPlanCollaboratorCount
+                      planCode={groupPlanToChangeToCode}
+                    />
+                  </strong>
+                </li>
+                <li>
+                  <strong>{t('all_premium_features')}</strong>
+                </li>
+                <li>{t('sync_dropbox_github')}</li>
+                <li>{t('full_doc_history')}</li>
+                <li>{t('track_changes')}</li>
+                <li>
+                  <span aria-hidden>+ {t('more').toLowerCase()}</span>
+                  <span className="sr-only">{t('plus_more')}</span>
+                </li>
+              </ul>
+            </div>
+
+            <div className="col-md-6">
+              <form className="form">
+                <fieldset className="form-group">
+                  <legend className="legend-as-label">{t('plan')}</legend>
+                  {groupPlans.plans.map(option => (
+                    <label
+                      htmlFor={`plan-option-${option.code}`}
+                      key={option.code}
+                      className="group-plan-option"
+                    >
+                      <input
+                        type="radio"
+                        name="plan-code"
+                        value={option.code}
+                        id={`plan-option-${option.code}`}
+                        onChange={e =>
+                          setGroupPlanToChangeToCode(e.target.value)
+                        }
+                        checked={option.code === groupPlanToChangeToCode}
+                      />
+                      <span>{option.display}</span>
+                    </label>
+                  ))}
+                </fieldset>
+
+                <div className="form-group">
+                  <label htmlFor="size">{t('number_of_users')}</label>
+                  <select
+                    name="size"
+                    id="size"
+                    className="form-control"
+                    value={groupPlanToChangeToSize}
+                    onChange={e => setGroupPlanToChangeToSize(e.target.value)}
+                  >
+                    {groupPlans.sizes.map(size => (
+                      <option key={`size-option-${size}`}>{size}</option>
+                    ))}
+                  </select>
+                </div>
+
+                <div className="form-group">
+                  <strong>
+                    <Trans
+                      i18nKey="percent_discount_for_groups"
+                      values={{
+                        percent: educationalPercentDiscount,
+                        size: groupSizeForEducationalDiscount,
+                      }}
+                    />
+                  </strong>
+                </div>
+
+                <div className="form-group group-plan-option">
+                  <label htmlFor="usage">
+                    <input
+                      id="usage"
+                      type="checkbox"
+                      checked={groupPlanToChangeToUsage === 'educational'}
+                      onChange={e => {
+                        if (e.target.checked) {
+                          setGroupPlanToChangeToUsage('educational')
+                        } else {
+                          setGroupPlanToChangeToUsage('enterprise')
+                        }
+                      }}
+                    />
+                    <span>{t('license_for_educational_purposes')}</span>
+                  </label>
+                </div>
+              </form>
+            </div>
+          </div>
+          <div className="row">
+            <div className="col-md-12 text-center">
+              <div className="educational-discount-badge">
+                {groupPlanToChangeToUsage === 'educational' && (
+                  <EducationDiscountAppliedOrNot
+                    groupSize={groupPlanToChangeToSize}
+                  />
+                )}
+              </div>
+            </div>
+          </div>
+        </div>
+      </Modal.Body>
+
+      <Modal.Footer>
+        <div className="text-center">
+          <p>
+            <strong>{t('new_subscription_will_be_billed_immediately')}</strong>
+          </p>
+          <hr className="thin" />
+          <button className="btn btn-primary btn-lg">{t('upgrade_now')}</button>
+          <hr className="thin" />
+          <button
+            className="btn-inline-link"
+            data-ol-open-contact-form-for-more-than-50-licenses
+          >
+            <Trans i18nKey="need_more_than_x_licenses" values={{ x: 50 }} />{' '}
+            {t('please_get_in_touch')}
+          </button>
+        </div>
+      </Modal.Footer>
+    </AccessibleModal>
+  )
+}

+ 7 - 6
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/confirm-change-plan-modal.tsx → services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx

@@ -1,14 +1,15 @@
 import { useState } from 'react'
 import { Modal } from 'react-bootstrap'
 import { useTranslation, Trans } from 'react-i18next'
-import { postJSON } from '../../../../../../../infrastructure/fetch-json'
-import AccessibleModal from '../../../../../../../shared/components/accessible-modal'
-import getMeta from '../../../../../../../utils/meta'
-import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
-import { subscriptionUrl } from '../../../../../data/subscription-url'
+import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
+import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
+import AccessibleModal from '../../../../../../../../shared/components/accessible-modal'
+import getMeta from '../../../../../../../../utils/meta'
+import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
+import { subscriptionUrl } from '../../../../../../data/subscription-url'
 
 export function ConfirmChangePlanModal() {
-  const modalId = 'change-to-plan'
+  const modalId: SubscriptionDashModalIds = 'change-to-plan'
   const [error, setError] = useState(false)
   const [inflight, setInflight] = useState(false)
   const { t } = useTranslation()

+ 6 - 5
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/keep-current-plan-modal.tsx → services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/keep-current-plan-modal.tsx

@@ -1,13 +1,14 @@
 import { useState } from 'react'
 import { Modal } from 'react-bootstrap'
 import { useTranslation, Trans } from 'react-i18next'
-import { postJSON } from '../../../../../../../infrastructure/fetch-json'
-import AccessibleModal from '../../../../../../../shared/components/accessible-modal'
-import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
-import { cancelPendingSubscriptionChangeUrl } from '../../../../../data/subscription-url'
+import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
+import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
+import AccessibleModal from '../../../../../../../../shared/components/accessible-modal'
+import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
+import { cancelPendingSubscriptionChangeUrl } from '../../../../../../data/subscription-url'
 
 export function KeepCurrentPlanModal() {
-  const modalId = 'keep-current-plan'
+  const modalId: SubscriptionDashModalIds = 'keep-current-plan'
   const [error, setError] = useState(false)
   const [inflight, setInflight] = useState(false)
   const { t } = useTranslation()

+ 39 - 5
services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx

@@ -17,22 +17,36 @@ import { Institution } from '../../../../../types/institution'
 import getMeta from '../../../utils/meta'
 import { loadDisplayPriceWithTaxPromise } from '../util/recurly-pricing'
 import { isRecurlyLoaded } from '../util/is-recurly-loaded'
+import { SubscriptionDashModalIds } from '../../../../../types/subscription/dashboard/modal-ids'
 
 type SubscriptionDashboardContextValue = {
+  groupPlanToChangeToCode?: string
+  groupPlanToChangeToSize: string
+  groupPlanToChangeToUsage?: string
   handleCloseModal: () => void
-  handleOpenModal: (modalIdToOpen: string, planCode?: string) => void
+  handleOpenModal: (
+    modalIdToOpen: SubscriptionDashModalIds,
+    planCode?: string
+  ) => void
   hasDisplayedSubscription: boolean
   institutionMemberships?: Institution[]
   managedGroupSubscriptions: ManagedGroupSubscription[]
   managedInstitutions: ManagedInstitution[]
   updateManagedInstitution: (institution: ManagedInstitution) => void
-  modalIdShown?: string
+  modalIdShown?: SubscriptionDashModalIds
   personalSubscription?: Subscription
   plans: Plan[]
   planCodeToChangeTo?: string
   queryingIndividualPlansData: boolean
   recurlyLoadError: boolean
-  setModalIdShown: React.Dispatch<React.SetStateAction<string | undefined>>
+  setGroupPlanToChangeToCode: React.Dispatch<
+    React.SetStateAction<string | undefined>
+  >
+  setGroupPlanToChangeToSize: React.Dispatch<React.SetStateAction<string>>
+  setGroupPlanToChangeToUsage: React.Dispatch<React.SetStateAction<string>>
+  setModalIdShown: React.Dispatch<
+    React.SetStateAction<SubscriptionDashModalIds | undefined>
+  >
   setPlanCodeToChangeTo: React.Dispatch<
     React.SetStateAction<string | undefined>
   >
@@ -52,7 +66,9 @@ export function SubscriptionDashboardProvider({
 }: {
   children: ReactNode
 }) {
-  const [modalIdShown, setModalIdShown] = useState<string | undefined>()
+  const [modalIdShown, setModalIdShown] = useState<
+    SubscriptionDashModalIds | undefined
+  >()
   const [recurlyLoadError, setRecurlyLoadError] = useState(false)
   const [showCancellation, setShowCancellation] = useState(false)
   const [showChangePersonalPlan, setShowChangePersonalPlan] = useState(false)
@@ -62,6 +78,12 @@ export function SubscriptionDashboardProvider({
   const [planCodeToChangeTo, setPlanCodeToChangeTo] = useState<
     string | undefined
   >()
+  const [groupPlanToChangeToSize, setGroupPlanToChangeToSize] = useState('10')
+  const [groupPlanToChangeToCode, setGroupPlanToChangeToCode] = useState<
+    string | undefined
+  >()
+  const [groupPlanToChangeToUsage, setGroupPlanToChangeToUsage] =
+    useState('enterprise')
 
   const plansWithoutDisplayPrice = getMeta('ol-plans')
   const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence')
@@ -124,7 +146,7 @@ export function SubscriptionDashboardProvider({
     []
   )
   const handleCloseModal = useCallback(() => {
-    setModalIdShown('')
+    setModalIdShown(undefined)
     setPlanCodeToChangeTo(undefined)
   }, [setModalIdShown, setPlanCodeToChangeTo])
 
@@ -138,6 +160,9 @@ export function SubscriptionDashboardProvider({
 
   const value = useMemo<SubscriptionDashboardContextValue>(
     () => ({
+      groupPlanToChangeToCode,
+      groupPlanToChangeToSize,
+      groupPlanToChangeToUsage,
       handleCloseModal,
       handleOpenModal,
       hasDisplayedSubscription,
@@ -151,6 +176,9 @@ export function SubscriptionDashboardProvider({
       planCodeToChangeTo,
       queryingIndividualPlansData,
       recurlyLoadError,
+      setGroupPlanToChangeToCode,
+      setGroupPlanToChangeToSize,
+      setGroupPlanToChangeToUsage,
       setModalIdShown,
       setPlanCodeToChangeTo,
       setRecurlyLoadError,
@@ -160,6 +188,9 @@ export function SubscriptionDashboardProvider({
       setShowChangePersonalPlan,
     }),
     [
+      groupPlanToChangeToCode,
+      groupPlanToChangeToSize,
+      groupPlanToChangeToUsage,
       handleCloseModal,
       handleOpenModal,
       hasDisplayedSubscription,
@@ -173,6 +204,9 @@ export function SubscriptionDashboardProvider({
       planCodeToChangeTo,
       queryingIndividualPlansData,
       recurlyLoadError,
+      setGroupPlanToChangeToCode,
+      setGroupPlanToChangeToSize,
+      setGroupPlanToChangeToUsage,
       setModalIdShown,
       setPlanCodeToChangeTo,
       setRecurlyLoadError,

+ 10 - 0
services/web/frontend/stylesheets/components/forms.less

@@ -32,6 +32,16 @@ label {
   display: inline-block;
   margin-bottom: 5px;
   font-weight: bold;
+
+  // also update .legend-as-label if changes are made to label
+}
+
+.legend-as-label {
+  // display a legend like a label
+  &:extend(label);
+  font-size: @font-size-base;
+  color: @text-color;
+  border: 0;
 }
 
 // Normalize form controls

+ 4 - 0
services/web/frontend/stylesheets/components/lists.less

@@ -48,3 +48,7 @@
     }
   }
 }
+
+.list-item-with-margin-bottom {
+  margin-bottom: @line-height-computed;
+}

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

@@ -295,6 +295,7 @@
   "credit_card": "Credit Card",
   "credit_card_number": "Credit Card Number",
   "cs": "Czech",
+  "currency": "Currency",
   "current_experiments": "Current Experiments",
   "current_file": "Current file",
   "current_password": "Current Password",
@@ -304,6 +305,7 @@
   "custom_resource_portal": "Custom resource portal",
   "custom_resource_portal_info": "You can have your own custom portal page on Overleaf. This is a great place for your users to find out more about Overleaf, access templates, FAQs and Help resources, and sign up to Overleaf.",
   "customize": "Customize",
+  "customize_your_group_subscription": "Customize your group subscription",
   "customize_your_plan": "Customize your plan",
   "da": "Danish",
   "date": "Date",
@@ -387,6 +389,7 @@
   "dropbox_unlinked_premium_feature": "<0>Your Dropbox account has been unlinked</0> because Dropbox Sync is a premium feature that you had through an institutional license.",
   "duplicate_file": "Duplicate File",
   "duplicate_projects": "This user has projects with duplicate names",
+  "each_user_will_have_access_to": "Each user will have access to",
   "ease_of_use": " Ease of Use",
   "easily_manage_your_project_files_everywhere": "Easily manage your project files, everywhere",
   "edit": "Edit",
@@ -400,6 +403,8 @@
   "editor_only_hide_pdf": "Editor only <0>(hide PDF)</0>",
   "editor_resources": "Editor Resources",
   "editor_theme": "Editor theme",
+  "educational_discount_for_groups_of_x_or_more": "The educational discount is available for groups of __size__ or more",
+  "educational_percent_discount_applied": "__percent__% educational discount applied!",
   "email": "Email",
   "email_already_associated_with": "The <b>__email1__</b> email is already associated with the <b>__email2__</b> <b>__appName__</b> account.",
   "email_already_registered": "This email is already registered",
@@ -807,6 +812,7 @@
   "leave_projects": "Leave Projects",
   "let_us_know": "Let us know",
   "license": "License",
+  "license_for_educational_purposes": "This license is for educational purposes (applies to students or faculty using __appName__ for teaching)",
   "limited_offer": "Limited offer",
   "line_height": "Line Height",
   "link": "Link",
@@ -930,6 +936,7 @@
   "navigation": "Navigation",
   "nearly_activated": "You’re one step away from activating your __appName__ account!",
   "need_anything_contact_us_at": "If there is anything you ever need please feel free to contact us directly at",
+  "need_more_than_x_licenses": "Need more than __x__ licenses?",
   "need_to_add_new_primary_before_remove": "You’ll need to add a new primary email address before you can remove this one.",
   "need_to_leave": "Need to leave?",
   "need_to_upgrade_for_more_collabs": "You need to upgrade your account to add more collaborators",
@@ -940,6 +947,7 @@
   "new_password": "New Password",
   "new_project": "New Project",
   "new_snippet_project": "Untitled",
+  "new_subscription_will_be_billed_immediately": "Your new subscription will be billed immediately to your current payment method.",
   "new_to_latex_look_at": "New to LaTeX? Start by having a look at our",
   "newsletter": "Newsletter",
   "newsletter-accept": "I’d like emails about product offers and company news and events.",
@@ -1062,10 +1070,12 @@
   "pdf_viewer_error": "There was a problem displaying the PDF for this project.",
   "pending": "Pending",
   "pending_additional_licenses": "Your subscription is changing to include <0>__pendingAdditionalLicenses__</0> additional license(s) for a total of <1>__pendingTotalLicenses__</1> licenses.",
+  "percent_discount_for_groups": "__appName__ offers a __percent__% educational discount for groups of __size__ or more.",
   "personal": "Personal",
   "personalized_onboarding": "Personalized onboarding",
   "personalized_onboarding_info": "We’ll help you get everything set up and then we’re here to answer questions from your users about the platform, templates or LaTeX!",
   "pl": "Polish",
+  "plan": "Plan",
   "plan_tooltip": "You’re on the __plan__ plan. Click to find out how to make the most of your Overleaf premium features!",
   "planned_maintenance": "Planned Maintenance",
   "plans_amper_pricing": "Plans &amp; Pricing",
@@ -1079,6 +1089,7 @@
   "please_confirm_email": "Please confirm your email __emailAddress__ by clicking on the link in the confirmation email ",
   "please_confirm_your_email_before_making_it_default": "Please confirm your email before making it the primary.",
   "please_enter_email": "Please enter your email address",
+  "please_get_in_touch": "Please get in touch",
   "please_link_before_making_primary": "Please confirm your email by linking to your institutional account before making it the primary email.",
   "please_reconfirm_institutional_email": "Please take a moment to confirm your institutional email address or <0>remove it</0> from your account.",
   "please_reconfirm_your_affiliation_before_making_this_primary": "Please confirm your affiliation before making this the primary.",
@@ -1089,6 +1100,7 @@
   "please_select_an_output_file": "Please Select an Output File",
   "please_set_a_password": "Please set a password",
   "please_set_main_file": "Please choose the main file for this project in the project menu.  ",
+  "plus_more": "plus more",
   "plus_upgraded_accounts_receive": "Plus with an upgraded account you get",
   "portal_add_affiliation_to_join": "It looks like you are already logged in to __appName__! If you have a __portalTitle__ email you can add it now.",
   "position": "Position",
@@ -1262,6 +1274,7 @@
   "save_or_cancel-cancel": "Cancel",
   "save_or_cancel-or": "or",
   "save_or_cancel-save": "Save",
+  "save_x_percent_or_more": "Save __percent__% or more",
   "saved_bibtex_appended_to_galileo_bib": "The <strong>__citeKey__</strong> cite key has been added to the <strong>__galileoBib__</strong> file in your project.",
   "saved_bibtex_to_new_galileo_bib": "The <strong>__citeKey__</strong> cite key has been copied into a new <strong>__galileoBib__</strong> file in your project. Include this file in your project using the appropriate method for your citation package.",
   "saving": "Saving",
@@ -1658,6 +1671,8 @@
   "x_price_for_first_month": "<0>__price__</0> for your first month",
   "x_price_for_first_year": "<0>__price__</0> for your first year",
   "x_price_for_y_months": "<0>__price__</0> for your first __discountMonths__ months",
+  "x_price_per_user": "__price__ per user",
+  "x_price_per_year": "__price__ per year",
   "year": "year",
   "yes_move_me_to_personal_plan": "Yes, move me to the Personal plan",
   "yes_that_is_correct": "Yes, that’s correct",

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

@@ -1,7 +1,7 @@
 import { expect } from 'chai'
 import { fireEvent, screen, waitFor, within } from '@testing-library/react'
 import { ChangePlan } from '../../../../../../../../../frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-plan'
-import { plans } from '../../../../../fixtures/plans'
+import { groupPlans, plans } from '../../../../../fixtures/plans'
 import {
   annualActiveSubscription,
   pendingSubscriptionChange,
@@ -322,4 +322,104 @@ describe('<ChangePlan />', function () {
       ).to.not.exist
     })
   })
+
+  describe('Change to group plan modal', function () {
+    const standardPlanCollaboratorText = '10 collaborators per project'
+    const professionalPlanCollaboratorText = 'Unlimited collaborators'
+    it('open group plan modal "Change to a group plan" clicked', async function () {
+      renderActiveSubscription(annualActiveSubscription)
+
+      const button = screen.getByRole('button', { name: 'Change plan' })
+      fireEvent.click(button)
+
+      const buttonGroupModal = await screen.findByRole('button', {
+        name: 'Change to a group plan',
+      })
+      fireEvent.click(buttonGroupModal)
+
+      const modal = await screen.findByRole('dialog')
+
+      within(modal).getByText('Customize your group subscription')
+      within(modal).getByText('Save 30% or more')
+      within(modal).getByText('Each user will have access to:')
+      within(modal).getByText('All premium features')
+      within(modal).getByText('Sync with Dropbox and GitHub')
+      within(modal).getByText('Full document history')
+      within(modal).getByText('plus more')
+
+      within(modal).getByText(standardPlanCollaboratorText)
+      expect(within(modal).queryByText(professionalPlanCollaboratorText)).to.be
+        .null
+
+      const plans = within(modal).getByRole('group')
+      const planOptions = within(plans).getAllByRole('radio')
+      expect(planOptions.length).to.equal(groupPlans.plans.length)
+
+      const sizeSelect = within(modal).getByRole('combobox')
+      const sizeOption = within(sizeSelect).getAllByRole('option')
+      expect(sizeOption.length).to.equal(groupPlans.sizes.length)
+      within(modal).getByText(
+        'Overleaf offers a 40% educational discount for groups of 10 or more.'
+      )
+
+      within(modal).getByRole('checkbox')
+      within(modal).getByText(
+        'This license is for educational purposes (applies to students or faculty using Overleaf for teaching)'
+      )
+
+      within(modal).getByText(
+        'Your new subscription will be billed immediately to your current payment method.'
+      )
+
+      within(modal).getByRole('button', { name: 'Upgrade Now' })
+
+      within(modal).getByRole('button', {
+        name: 'Need more than 50 licenses? Please get in touch',
+      })
+    })
+
+    it('changes the collaborator count when the plan changes', async function () {
+      renderActiveSubscription(annualActiveSubscription)
+
+      const button = screen.getByRole('button', { name: 'Change plan' })
+      fireEvent.click(button)
+
+      const buttonGroupModal = await screen.findByRole('button', {
+        name: 'Change to a group plan',
+      })
+      fireEvent.click(buttonGroupModal)
+
+      const modal = await screen.findByRole('dialog')
+      const professionalPlanOption =
+        within(modal).getByLabelText('Professional')
+      fireEvent.click(professionalPlanOption)
+
+      within(modal).getByText(professionalPlanCollaboratorText)
+      expect(within(modal).queryByText(standardPlanCollaboratorText)).to.be.null
+    })
+
+    it('shows educational discount applied when input checked', async function () {
+      const discountAppliedText = '40% educational discount applied!'
+      const discountNotAppliedText =
+        'The educational discount is available for groups of 10 or more'
+      renderActiveSubscription(annualActiveSubscription)
+
+      const button = screen.getByRole('button', { name: 'Change plan' })
+      fireEvent.click(button)
+
+      const buttonGroupModal = await screen.findByRole('button', {
+        name: 'Change to a group plan',
+      })
+      fireEvent.click(buttonGroupModal)
+
+      const modal = await screen.findByRole('dialog')
+
+      const educationInput = within(modal).getByLabelText(
+        'This license is for educational purposes (applies to students or faculty using Overleaf for teaching)'
+      )
+      fireEvent.click(educationInput)
+      within(modal).getByText(discountAppliedText)
+      expect(within(modal).queryByText(discountNotAppliedText)).to.be.null
+    })
+  })
 })

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

@@ -1,3 +1,4 @@
+import { GroupPlans } from '../../../../../types/subscription/dashboard/group-plans'
 import { Plan } from '../../../../../types/subscription/plan'
 
 const features = {
@@ -213,3 +214,17 @@ export const plans = [
   ...individualMonthlyPlans,
   ...individualAnnualPlans,
 ]
+
+export const groupPlans: GroupPlans = {
+  plans: [
+    {
+      display: 'Standard',
+      code: 'collaborator',
+    },
+    {
+      display: 'Professional',
+      code: 'professional',
+    },
+  ],
+  sizes: ['2', '3', '4', '5', '10', '20', '50'],
+}

+ 5 - 1
services/web/test/frontend/features/subscription/helpers/render-active-subscription.tsx

@@ -1,6 +1,6 @@
 import { ActiveSubscription } from '../../../../../frontend/js/features/subscription/components/dashboard/states/active/active'
 import { Subscription } from '../../../../../types/subscription/dashboard/subscription'
-import { plans } from '../fixtures/plans'
+import { groupPlans, plans } from '../fixtures/plans'
 import { renderWithSubscriptionDashContext } from './render-with-subscription-dash-context'
 
 export function renderActiveSubscription(
@@ -11,6 +11,10 @@ export function renderActiveSubscription(
     metaTags: [
       ...tags,
       { name: 'ol-plans', value: plans },
+      {
+        name: 'ol-groupPlans',
+        value: groupPlans,
+      },
       { name: 'ol-subscription', value: subscription },
       {
         name: 'ol-recommendedCurrency',

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

@@ -0,0 +1,7 @@
+export type GroupPlans = {
+  plans: {
+    display: string
+    code: string
+  }[]
+  sizes: string[]
+}

+ 4 - 0
services/web/types/subscription/dashboard/modal-ids.ts

@@ -0,0 +1,4 @@
+export type SubscriptionDashModalIds =
+  | 'change-to-plan'
+  | 'change-to-group'
+  | 'keep-current-plan'