From f166e9263c875c0a8cd577014ad574dd16e77967 Mon Sep 17 00:00:00 2001 From: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com> Date: Tue, 4 Feb 2025 14:36:27 +0200 Subject: [PATCH] Merge pull request #23117 from overleaf/ii-flexible-group-licensing-subscription-page [web] Subscription page for flexible licensing GitOrigin-RevId: 8f2fab1fc01e27063d716a86add66b1b9a72cbe6 --- .../Features/Subscription/RecurlyEntities.js | 1 + .../Subscription/SubscriptionController.js | 46 +++ .../SubscriptionGroupController.mjs | 3 + .../Subscription/SubscriptionGroupHandler.js | 13 +- .../SubscriptionViewModelBuilder.js | 86 ++++- .../views/subscriptions/dashboard-react.pug | 2 + .../web/frontend/extracted-translations.json | 18 ++ .../components/add-seats/root.tsx | 7 +- .../components/upgrade-subscription/root.tsx | 14 + .../dashboard/group-settings-button.tsx | 100 +++++- .../dashboard/managed-group-subscriptions.tsx | 35 ++- .../dashboard/personal-subscription.tsx | 8 +- .../dashboard/premium-features-link.tsx | 28 +- .../components/dashboard/row-link.tsx | 2 +- .../dashboard/states/active/active-new.tsx | 297 ++++++++++++++++++ .../dashboard/states/active/add-ons.tsx | 144 +++++++++ .../dashboard/states/active/trial-ending.tsx | 12 +- .../components/dashboard/states/canceled.tsx | 7 +- .../components/dashboard/states/expired.tsx | 12 +- .../ui/components/bootstrap-5/tag.tsx | 10 +- .../js/features/ui/components/ol/ol-tag.tsx | 1 + .../upgrade-group-subscription.tsx | 4 +- .../web/frontend/js/shared/components/tag.tsx | 9 +- .../web/frontend/js/shared/svgs/sparkle.svg | 1 + services/web/frontend/js/utils/meta.ts | 1 + .../stylesheets/app/subscription.less | 65 +++- .../bootstrap-5/components/badge.scss | 5 + .../bootstrap-5/pages/subscription.scss | 49 ++- .../stylesheets/components/badge.less | 1 + .../stylesheets/components/group-members.less | 5 + services/web/locales/en.json | 18 ++ .../subscription/fixtures/subscriptions.ts | 33 ++ .../SubscriptionGroupControllerTests.mjs | 19 ++ .../SubscriptionGroupHandlerTests.js | 65 +++- .../subscription/dashboard/subscription.ts | 5 +- services/web/types/subscription/plan.ts | 1 + 36 files changed, 1060 insertions(+), 67 deletions(-) create mode 100644 services/web/frontend/js/features/group-management/components/upgrade-subscription/root.tsx create mode 100644 services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx create mode 100644 services/web/frontend/js/features/subscription/components/dashboard/states/active/add-ons.tsx create mode 100644 services/web/frontend/js/shared/svgs/sparkle.svg diff --git a/services/web/app/src/Features/Subscription/RecurlyEntities.js b/services/web/app/src/Features/Subscription/RecurlyEntities.js index adfb876157..aafa3eb8bb 100644 --- a/services/web/app/src/Features/Subscription/RecurlyEntities.js +++ b/services/web/app/src/Features/Subscription/RecurlyEntities.js @@ -425,6 +425,7 @@ function isStandaloneAiAddOnPlanCode(planCode) { module.exports = { AI_ADD_ON_CODE, MEMBERS_LIMIT_ADD_ON_CODE, + STANDALONE_AI_ADD_ON_CODES, RecurlySubscription, RecurlySubscriptionAddOn, RecurlySubscriptionChange, diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 74f53f20be..21ebd8534f 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -61,6 +61,13 @@ async function userSubscriptionPage(req, res) { 'bootstrap-5-subscription' ) + const { variant: flexibleLicensingVariant } = + await SplitTestHandler.promises.getAssignment( + req, + res, + 'flexible-group-licensing' + ) + const results = await SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( user, @@ -124,6 +131,42 @@ async function userSubscriptionPage(req, res) { ) } + let groupSettingsAdvertisedFor + try { + const managedGroups = await async.filter( + managedGroupSubscriptions || [], + async subscription => { + const managedUsersResults = await Modules.promises.hooks.fire( + 'hasManagedUsersFeatureOnNonProfessionalPlan', + subscription + ) + const groupSSOResults = await Modules.promises.hooks.fire( + 'hasGroupSSOFeatureOnNonProfessionalPlan', + subscription + ) + const isGroupAdmin = + (subscription.admin_id._id || subscription.admin_id).toString() === + user._id.toString() + const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode) + return ( + (managedUsersResults?.[0] === true || + groupSSOResults?.[0] === true) && + isGroupAdmin && + flexibleLicensingVariant === 'enabled' && + plan?.canUseFlexibleLicensing + ) + } + ) + groupSettingsAdvertisedFor = managedGroups.map(subscription => + subscription._id.toString() + ) + } catch (error) { + logger.error( + { err: error }, + 'Failed to list groups with group settings enabled for advertising' + ) + } + const data = { title: 'your_subscription', plans: plansData?.plans, @@ -138,7 +181,10 @@ async function userSubscriptionPage(req, res) { managedInstitutions, managedPublishers, currentInstitutionsWithLicence, + canUseFlexibleLicensing: + personalSubscription?.plan?.canUseFlexibleLicensing, groupPlans: groupPlansDataForDash, + groupSettingsAdvertisedFor, groupSettingsEnabledFor, isManagedAccount: !!req.managedBy, userRestrictions: Array.from(req.userRestrictions || []), diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs index 08778eb698..1647212c39 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs @@ -133,6 +133,9 @@ async function addSeatsToGroupSubscription(req, res) { await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan) // Check if the user has missing billing details await RecurlyClient.promises.getPaymentMethod(userId) + await SubscriptionGroupHandler.promises.ensureSubscriptionIsActive( + subscription + ) res.render('subscriptions/add-seats', { subscriptionId: subscription._id, diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js index c6a0269fea..22c845351e 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js @@ -62,6 +62,12 @@ async function ensureFlexibleLicensingEnabled(plan) { } } +async function ensureSubscriptionIsActive(subscription) { + if (subscription?.recurlyStatus?.state !== 'active') { + throw new Error('The subscription is not active') + } +} + async function getUsersGroupSubscriptionDetails(userId) { const subscription = await SubscriptionLocator.promises.getUsersSubscription(userId) @@ -89,9 +95,10 @@ async function getUsersGroupSubscriptionDetails(userId) { } async function _addSeatsSubscriptionChange(userId, adding) { - const { recurlySubscription, plan } = + const { subscription, recurlySubscription, plan } = await getUsersGroupSubscriptionDetails(userId) await ensureFlexibleLicensingEnabled(plan) + await ensureSubscriptionIsActive(subscription) const currentAddonQuantity = recurlySubscription.addOns.find( addOn => addOn.code === MEMBERS_LIMIT_ADD_ON_CODE @@ -193,6 +200,8 @@ async function _getGroupPlanUpgradeChangeRequest(ownerId) { const olSubscription = await SubscriptionLocator.promises.getUsersSubscription(ownerId) + await ensureSubscriptionIsActive(olSubscription) + const newPlanCode = await _getUpgradeTargetPlanCodeMaybeThrow(olSubscription) const recurlySubscription = await RecurlyClient.promises.getSubscription( olSubscription.recurlySubscription_id @@ -234,6 +243,7 @@ module.exports = { removeUserFromGroup: callbackify(removeUserFromGroup), replaceUserReferencesInGroups: callbackify(replaceUserReferencesInGroups), ensureFlexibleLicensingEnabled: callbackify(ensureFlexibleLicensingEnabled), + ensureSubscriptionIsActive: callbackify(ensureSubscriptionIsActive), getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup), isUserPartOfGroup: callbackify(isUserPartOfGroup), getGroupPlanUpgradePreview: callbackify(getGroupPlanUpgradePreview), @@ -242,6 +252,7 @@ module.exports = { removeUserFromGroup, replaceUserReferencesInGroups, ensureFlexibleLicensingEnabled, + ensureSubscriptionIsActive, getTotalConfirmedUsersInGroup, isUserPartOfGroup, getUsersGroupSubscriptionDetails, diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js index 6274f4d3bd..aa3d577991 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js +++ b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js @@ -2,7 +2,10 @@ const Settings = require('@overleaf/settings') const RecurlyWrapper = require('./RecurlyWrapper') const PlansLocator = require('./PlansLocator') -const { isStandaloneAiAddOnPlanCode } = require('./RecurlyEntities') +const { + isStandaloneAiAddOnPlanCode, + MEMBERS_LIMIT_ADD_ON_CODE, +} = require('./RecurlyEntities') const SubscriptionFormatters = require('./SubscriptionFormatters') const SubscriptionLocator = require('./SubscriptionLocator') const SubscriptionUpdater = require('./SubscriptionUpdater') @@ -240,6 +243,51 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { delete personalSubscription.recurly } + function getPlanOnlyDisplayPrice( + totalPlanPriceInCents, + taxRate, + addOns = [] + ) { + // The MEMBERS_LIMIT_ADD_ON_CODE is considered as part of the new plan model + const allAddOnsPriceInCentsExceptAdditionalLicensePrice = addOns.reduce( + (prev, curr) => { + return curr.add_on_code !== MEMBERS_LIMIT_ADD_ON_CODE + ? curr.quantity * curr.unit_amount_in_cents + prev + : prev + }, + 0 + ) + const allAddOnsTotalPriceInCentsExceptAdditionalLicensePrice = + allAddOnsPriceInCentsExceptAdditionalLicensePrice + + allAddOnsPriceInCentsExceptAdditionalLicensePrice * taxRate + + return SubscriptionFormatters.formatPriceLocalized( + totalPlanPriceInCents - + allAddOnsTotalPriceInCentsExceptAdditionalLicensePrice, + recurlySubscription.currency, + locale + ) + } + + function getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns = []) { + return addOns.reduce((prev, curr) => { + if (curr.add_on_code !== MEMBERS_LIMIT_ADD_ON_CODE) { + const priceInCents = curr.quantity * curr.unit_amount_in_cents + const totalPriceInCents = priceInCents + priceInCents * taxRate + + if (totalPriceInCents > 0) { + prev[curr.add_on_code] = SubscriptionFormatters.formatPriceLocalized( + totalPriceInCents, + recurlySubscription.currency, + locale + ) + } + } + + return prev + }, {}) + } + if (personalSubscription && recurlySubscription) { const tax = recurlySubscription.tax_in_cents || 0 // Some plans allow adding more seats than the base plan provides. @@ -248,6 +296,9 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { let addOnPrice = 0 let additionalLicenses = 0 const addOns = recurlySubscription.subscription_add_ons || [] + const taxRate = recurlySubscription.tax_rate + ? parseFloat(recurlySubscription.tax_rate._) + : 0 addOns.forEach(addOn => { addOnPrice += addOn.quantity * addOn.unit_amount_in_cents if (addOn.add_on_code === plan.membersLimitAddOn) { @@ -257,9 +308,7 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { const totalLicenses = (plan.membersLimit || 0) + additionalLicenses personalSubscription.recurly = { tax, - taxRate: recurlySubscription.tax_rate - ? parseFloat(recurlySubscription.tax_rate._) - : 0, + taxRate, billingDetailsLink: buildHostedLink('billing-details'), accountManagementLink: buildHostedLink('account-management'), additionalLicenses, @@ -311,12 +360,14 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { const pendingSubscriptionTax = personalSubscription.recurly.taxRate * recurlySubscription.pending_subscription.unit_amount_in_cents + const totalPriceInCents = + recurlySubscription.pending_subscription.unit_amount_in_cents + + pendingAddOnPrice + + pendingAddOnTax + + pendingSubscriptionTax personalSubscription.recurly.displayPrice = SubscriptionFormatters.formatPriceLocalized( - recurlySubscription.pending_subscription.unit_amount_in_cents + - pendingAddOnPrice + - pendingAddOnTax + - pendingSubscriptionTax, + totalPriceInCents, recurlySubscription.currency, locale ) @@ -326,6 +377,17 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { recurlySubscription.currency, locale ) + personalSubscription.recurly.planOnlyDisplayPrice = + getPlanOnlyDisplayPrice( + totalPriceInCents, + taxRate, + recurlySubscription.pending_subscription.subscription_add_ons + ) + personalSubscription.recurly.addOnDisplayPricesWithoutAdditionalLicense = + getAddOnDisplayPricesWithoutAdditionalLicense( + taxRate, + recurlySubscription.pending_subscription.subscription_add_ons + ) const pendingTotalLicenses = (pendingPlan.membersLimit || 0) + pendingAdditionalLicenses personalSubscription.recurly.pendingAdditionalLicenses = @@ -333,12 +395,18 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') { personalSubscription.recurly.pendingTotalLicenses = pendingTotalLicenses personalSubscription.pendingPlan = pendingPlan } else { + const totalPriceInCents = + recurlySubscription.unit_amount_in_cents + addOnPrice + tax personalSubscription.recurly.displayPrice = SubscriptionFormatters.formatPriceLocalized( - recurlySubscription.unit_amount_in_cents + addOnPrice + tax, + totalPriceInCents, recurlySubscription.currency, locale ) + personalSubscription.recurly.planOnlyDisplayPrice = + getPlanOnlyDisplayPrice(totalPriceInCents, taxRate, addOns) + personalSubscription.recurly.addOnDisplayPricesWithoutAdditionalLicense = + getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns) } } diff --git a/services/web/app/views/subscriptions/dashboard-react.pug b/services/web/app/views/subscriptions/dashboard-react.pug index 69f71fbc99..d39ec01a37 100644 --- a/services/web/app/views/subscriptions/dashboard-react.pug +++ b/services/web/app/views/subscriptions/dashboard-react.pug @@ -22,6 +22,8 @@ block append meta meta(name="ol-hasSubscription" data-type="boolean" content=hasSubscription) meta(name="ol-fromPlansPage" data-type="boolean" content=fromPlansPage) meta(name="ol-plans", data-type="json" content=plans) + meta(name="ol-groupSettingsAdvertisedFor", data-type="json" content=groupSettingsAdvertisedFor) + meta(name="ol-canUseFlexibleLicensing", data-type="boolean", content=canUseFlexibleLicensing) meta(name="ol-groupSettingsEnabledFor", data-type="json" content=groupSettingsEnabledFor) meta(name="ol-user" data-type="json" content=user) if (personalSubscription && personalSubscription.recurly) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index b80b275106..ec791c3fb9 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -79,6 +79,7 @@ "add_more_users_to_my_plan": "", "add_new_email": "", "add_on": "", + "add_ons": "", "add_ons_are": "", "add_or_remove_project_from_tag": "", "add_people": "", @@ -153,6 +154,7 @@ "autocompile_disabled_reason": "", "autocomplete": "", "autocomplete_references": "", + "available_with_group_professional": "", "back": "", "back_to_configuration": "", "back_to_editor": "", @@ -163,7 +165,10 @@ "beta_program_already_participating": "", "beta_program_benefits": "", "beta_program_not_participating": "", + "billed_annually_at": "", + "billed_monthly_at": "", "billed_yearly": "", + "billing": "", "binary_history_error": "", "blank_project": "", "blocked_filename": "", @@ -582,6 +587,7 @@ "get_in_touch": "", "get_more_compile_time": "", "get_most_subscription_by_checking_features": "", + "get_most_subscription_discover_premium_features": "", "get_symbol_palette": "", "get_track_changes": "", "git": "", @@ -638,9 +644,13 @@ "group_invite_has_been_sent_to_email": "", "group_libraries": "", "group_managed_by_group_administrator": "", + "group_management": "", + "group_managers": "", + "group_members": "", "group_plan_tooltip": "", "group_plan_upgrade_description": "", "group_plan_with_name_tooltip": "", + "group_settings": "", "group_sso_configuration_idp_metadata": "", "group_sso_configure_service_provider_in_idp": "", "group_sso_documentation_links": "", @@ -1290,6 +1300,7 @@ "removing": "", "rename": "", "rename_project": "", + "renews_on": "", "reopen": "", "reopen_comment_error_message": "", "reopen_comment_error_title": "", @@ -1564,6 +1575,8 @@ "suggested_fix_for_error_in_path": "", "suggestion_applied": "", "support_for_your_browser_is_ending_soon": "", + "supports_up_to_x_users": "", + "supports_up_to_x_users_incl_y_additional_licenses": "", "sure_you_want_to_cancel_plan_change": "", "sure_you_want_to_change_plan": "", "sure_you_want_to_delete": "", @@ -1820,6 +1833,7 @@ "upgrade_for_12x_more_compile_time": "", "upgrade_my_plan": "", "upgrade_now": "", + "upgrade_plan": "", "upgrade_summary": "", "upgrade_to_add_more_editors_and_access_collaboration_features": "", "upgrade_to_get_feature": "", @@ -1832,6 +1846,7 @@ "url_to_fetch_the_file_from": "", "us_gov_banner_government_purchasing": "", "us_gov_banner_small_business_reseller": "", + "usage_metrics": "", "use_a_different_password": "", "use_saml_metadata_to_configure_sso_with_idp": "", "use_your_own_machine": "", @@ -1861,6 +1876,7 @@ "verify_email_address_before_enabling_managed_users": "", "view": "", "view_all": "", + "view_billing_details": "", "view_code": "", "view_configuration": "", "view_group_members": "", @@ -1934,6 +1950,7 @@ "x_price_for_first_month": "", "x_price_for_first_year": "", "x_price_for_y_months": "", + "x_price_per_month": "", "x_price_per_user": "", "x_price_per_year": "", "year": "", @@ -1960,6 +1977,7 @@ "you_can_still_use_your_premium_features": "", "you_cant_add_or_change_password_due_to_sso": "", "you_cant_join_this_group_subscription": "", + "you_dont_have_any_add_ons_on_your_account": "", "you_dont_have_any_repositories": "", "you_have_0_free_suggestions_left": "", "you_have_1_free_suggestion_left": "", diff --git a/services/web/frontend/js/features/group-management/components/add-seats/root.tsx b/services/web/frontend/js/features/group-management/components/add-seats/root.tsx index af6d74928d..e6d9ae4700 100644 --- a/services/web/frontend/js/features/group-management/components/add-seats/root.tsx +++ b/services/web/frontend/js/features/group-management/components/add-seats/root.tsx @@ -1,6 +1,5 @@ import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n' import AddSeats from '@/features/group-management/components/add-seats/add-seats' -import { SplitTestProvider } from '@/shared/context/split-test-context' function Root() { const { isReady } = useWaitForI18n() @@ -9,11 +8,7 @@ function Root() { return null } - return ( - - - - ) + return } export default Root diff --git a/services/web/frontend/js/features/group-management/components/upgrade-subscription/root.tsx b/services/web/frontend/js/features/group-management/components/upgrade-subscription/root.tsx new file mode 100644 index 0000000000..3b82f3b25c --- /dev/null +++ b/services/web/frontend/js/features/group-management/components/upgrade-subscription/root.tsx @@ -0,0 +1,14 @@ +import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n' +import UpgradeSubscription from '@/features/group-management/components/upgrade-subscription/upgrade-subscription' + +function Root() { + const { isReady } = useWaitForI18n() + + if (!isReady) { + return null + } + + return +} + +export default Root diff --git a/services/web/frontend/js/features/subscription/components/dashboard/group-settings-button.tsx b/services/web/frontend/js/features/subscription/components/dashboard/group-settings-button.tsx index eb6876bd9e..26532ccd5d 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/group-settings-button.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/group-settings-button.tsx @@ -1,17 +1,49 @@ import { RowLink } from '@/features/subscription/components/dashboard/row-link' import { useTranslation } from 'react-i18next' +import { useFeatureFlag } from '@/shared/context/split-test-context' +import { useLocation } from '@/shared/hooks/use-location' +import MaterialIcon from '@/shared/components/material-icon' +import OLTag from '@/features/ui/components/ol/ol-tag' +import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' +import { bsVersion } from '@/features/utils/bootstrap-5' import { ManagedGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription' -export default function GroupSettingsButton({ - subscription, -}: { - subscription: ManagedGroupSubscription -}) { +function AvailableWithGroupProfessionalBadge() { const { t } = useTranslation() + const location = useLocation() + const handleUpgradeClick = () => { + location.assign('/user/subscription/group/upgrade-subscription') + } + + return ( + + ) +} + +function useGroupSettingsButton(subscription: ManagedGroupSubscription) { + const { t } = useTranslation() + const isFlexibleGroupLicensing = useFeatureFlag('flexible-group-licensing') const subscriptionHasManagedUsers = subscription.features?.managedUsers === true const subscriptionHasGroupSSO = subscription.features?.groupSSO === true + const heading = isFlexibleGroupLicensing + ? t('group_settings') + : t('manage_group_settings') let groupSettingRowSubText = '' if (subscriptionHasGroupSSO && subscriptionHasManagedUsers) { @@ -22,12 +54,68 @@ export default function GroupSettingsButton({ groupSettingRowSubText = t('manage_group_settings_subtext_managed_users') } + return { + heading, + groupSettingRowSubText, + } +} + +export function GroupSettingsButton({ + subscription, +}: { + subscription: ManagedGroupSubscription +}) { + const { heading, groupSettingRowSubText } = + useGroupSettingsButton(subscription) + return ( ) } + +export function GroupSettingsButtonWithAdBadge({ + subscription, +}: { + subscription: ManagedGroupSubscription +}) { + const { heading, groupSettingRowSubText } = + useGroupSettingsButton(subscription) + + return ( + +
+ +
+
+
{heading}
+
{groupSettingRowSubText}
+
+ + + + + } + bs5={ +
  • +
    + +
    + {heading} +
    {groupSettingRowSubText}
    +
    + + + +
    +
  • + } + /> + ) +} diff --git a/services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx b/services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx index 2645684ee9..c6b33d7c6b 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx @@ -1,9 +1,15 @@ -import GroupSettingsButton from '@/features/subscription/components/dashboard/group-settings-button' +import { + GroupSettingsButton, + GroupSettingsButtonWithAdBadge, +} from '@/features/subscription/components/dashboard/group-settings-button' import getMeta from '@/utils/meta' import { Trans, useTranslation } from 'react-i18next' import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context' import { RowLink } from './row-link' import { ManagedGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription' +import { bsVersion } from '@/features/utils/bootstrap-5' +import { useFeatureFlag } from '@/shared/context/split-test-context' +import classnames from 'classnames' function ManagedGroupAdministrator({ subscription, @@ -85,11 +91,14 @@ function ManagedGroupAdministrator({ export default function ManagedGroupSubscriptions() { const { t } = useTranslation() const { managedGroupSubscriptions } = useSubscriptionDashboardContext() + const isFlexibleGroupLicensing = useFeatureFlag('flexible-group-licensing') if (!managedGroupSubscriptions) { return null } + const groupSettingsAdvertisedFor = + getMeta('ol-groupSettingsAdvertisedFor') || [] const groupSettingsEnabledFor = getMeta('ol-groupSettingsEnabledFor') || [] return ( @@ -97,28 +106,46 @@ export default function ManagedGroupSubscriptions() { {managedGroupSubscriptions.map(subscription => { return (
    +

    + {t('group_management')} +

      {groupSettingsEnabledFor?.includes(subscription._id) && ( )} + {groupSettingsAdvertisedFor?.includes(subscription._id) && ( + + )} diff --git a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx index 401574c7d3..28e5999206 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx @@ -3,12 +3,14 @@ import { RecurlySubscription } from '../../../../../../types/subscription/dashbo import { ActiveSubscription } from './states/active/active' import { ActiveAiAddonSubscription } from './states/active/active-ai-addon' import { PausedSubscription } from './states/active/paused' +import { ActiveSubscriptionNew } from '@/features/subscription/components/dashboard/states/active/active-new' import { CanceledSubscription } from './states/canceled' import { ExpiredSubscription } from './states/expired' import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context' import PersonalSubscriptionRecurlySyncEmail from './personal-subscription-recurly-sync-email' import OLNotification from '@/features/ui/components/ol/ol-notification' import { isStandaloneAiPlanCode, AI_ADD_ON_CODE } from '../../data/add-on-codes' +import { useFeatureFlag } from '@/shared/context/split-test-context' function PastDueSubscriptionAlert({ subscription, @@ -42,6 +44,7 @@ function PersonalSubscriptionStates({ }) { const { t } = useTranslation() const state = subscription?.recurly.state + const isFlexibleGroupLicensing = useFeatureFlag('flexible-group-licensing') const hasAiAddon = subscription?.addOns?.some( addOn => addOn.addOnCode === AI_ADD_ON_CODE @@ -50,7 +53,10 @@ function PersonalSubscriptionStates({ const onAiStandalonePlan = isStandaloneAiPlanCode(subscription.planCode) const planHasAi = onAiStandalonePlan || hasAiAddon - if (state === 'active' && planHasAi) { + if (state === 'active' && isFlexibleGroupLicensing) { + // This version handles subscriptions with and without addons + return + } else if (state === 'active' && planHasAi) { return } else if (state === 'active') { return diff --git a/services/web/frontend/js/features/subscription/components/dashboard/premium-features-link.tsx b/services/web/frontend/js/features/subscription/components/dashboard/premium-features-link.tsx index b142bb8992..925f80e71b 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/premium-features-link.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/premium-features-link.tsx @@ -1,18 +1,28 @@ import { Trans } from 'react-i18next' +import { useFeatureFlag } from '@/shared/context/split-test-context' function PremiumFeaturesLink() { - const featuresPageLink = ( - // translation adds content - // eslint-disable-next-line jsx-a11y/anchor-has-content - - ) + const isFlexibleGroupLicensing = useFeatureFlag('flexible-group-licensing') return (

      - + {isFlexibleGroupLicensing ? ( + , + ]} + /> + ) : ( + , + ]} + /> + )}

      ) } diff --git a/services/web/frontend/js/features/subscription/components/dashboard/row-link.tsx b/services/web/frontend/js/features/subscription/components/dashboard/row-link.tsx index b022e325b6..fec6f03c53 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/row-link.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/row-link.tsx @@ -32,7 +32,7 @@ function BS3RowLink({ href, heading, subtext, icon }: RowLinkProps) { function BS5RowLink({ href, heading, subtext, icon }: RowLinkProps) { return (
    • - +
      {heading} diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx new file mode 100644 index 0000000000..25794c4567 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx @@ -0,0 +1,297 @@ +import { useTranslation, Trans } from 'react-i18next' +import { PriceExceptions } from '../../../shared/price-exceptions' +import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context' +import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription' +import { CancelSubscriptionButton } from './cancel-subscription-button' +import { CancelSubscription } from './cancel-plan/cancel-subscription' +import { PendingPlanChange } from './pending-plan-change' +import { TrialEnding } from './trial-ending' +import { ChangePlanModal } from './change-plan/modals/change-plan-modal' +import { ConfirmChangePlanModal } from './change-plan/modals/confirm-change-plan-modal' +import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal' +import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal' +import { CancelAiAddOnModal } from '@/features/subscription/components/dashboard/states/active/change-plan/modals/cancel-ai-add-on-modal' +import OLButton from '@/features/ui/components/ol/ol-button' +import isInFreeTrial from '../../../../util/is-in-free-trial' +import { bsVersion } from '@/features/utils/bootstrap-5' +import AddOns from '@/features/subscription/components/dashboard/states/active/add-ons' +import { + AI_ADD_ON_CODE, + AI_STANDALONE_PLAN_CODE, + isStandaloneAiPlanCode, +} from '@/features/subscription/data/add-on-codes' +import getMeta from '@/utils/meta' +import classnames from 'classnames' +import SubscriptionRemainder from '@/features/subscription/components/dashboard/states/active/subscription-remainder' + +export function ActiveSubscriptionNew({ + subscription, +}: { + subscription: RecurlySubscription +}) { + const { t } = useTranslation() + const { + recurlyLoadError, + setModalIdShown, + showCancellation, + institutionMemberships, + memberGroupSubscriptions, + } = useSubscriptionDashboardContext() + + if (showCancellation) return + + const onStandalonePlan = isStandaloneAiPlanCode(subscription.planCode) + + let planName + if (onStandalonePlan) { + planName = 'Overleaf Free' + if (institutionMemberships && institutionMemberships.length > 0) { + planName = 'Overleaf Professional' + } + if (memberGroupSubscriptions.length > 0) { + if ( + memberGroupSubscriptions.some(s => s.planLevelName === 'Professional') + ) { + planName = 'Overleaf Professional' + } else { + planName = 'Overleaf Standard' + } + } + } else { + planName = subscription.plan.name + } + + const handlePlanChange = () => setModalIdShown('change-plan') + const handleCancelClick = (addOnCode: string) => { + if ([AI_STANDALONE_PLAN_CODE, AI_ADD_ON_CODE].includes(addOnCode)) { + setModalIdShown('cancel-ai-add-on') + } + } + + const isLegacyPlan = + subscription.recurly.totalLicenses !== + subscription.recurly.additionalLicenses + + return ( + <> +

      + {t('billing')} +

      +

      + {subscription.plan.annual ? ( + , + // eslint-disable-next-line react/jsx-key + , + ]} + /> + ) : ( + , + // eslint-disable-next-line react/jsx-key + , + ]} + /> + )} +

      +

      + ]} // eslint-disable-line react/jsx-key + /> +

      +
      +
      + + {!recurlyLoadError && ( +

      + + + +

      + )} +
      +
      +

      + {t('plan')} +

      +

      + {planName} +

      +

      + {subscription.pendingPlan && ( + <> + {' '} + + + )} +

      + {subscription.pendingPlan && + subscription.pendingPlan.name !== subscription.plan.name && ( +

      {t('want_change_to_apply_before_plan_end')}

      + )} + {isInFreeTrial(subscription.recurly.trial_ends_at) && + subscription.recurly.trialEndsAtFormatted && ( + + )} + {!subscription.pendingPlan && subscription.recurly.totalLicenses > 0 && ( +

      + {isLegacyPlan && subscription.recurly.additionalLicenses > 0 ? ( + , ]} // eslint-disable-line react/jsx-key + /> + ) : ( + ]} // eslint-disable-line react/jsx-key + /> + )} +

      + )} + {!onStandalonePlan && ( +

      + {subscription.plan.annual + ? t('x_price_per_year', { + price: subscription.recurly.planOnlyDisplayPrice, + }) + : t('x_price_per_month', { + price: subscription.recurly.planOnlyDisplayPrice, + })} +

      + )} + {!recurlyLoadError && ( + + )} +
      + + + + + + + + + ) +} + +type PlanActionsProps = { + subscription: RecurlySubscription + onStandalonePlan: boolean + handlePlanChange: () => void +} + +function PlanActions({ + subscription, + onStandalonePlan, + handlePlanChange, +}: PlanActionsProps) { + const { t } = useTranslation() + const isSubscriptionEligibleForFlexibleGroupLicensing = getMeta( + 'ol-canUseFlexibleLicensing' + ) + + return ( +
      + {isSubscriptionEligibleForFlexibleGroupLicensing ? ( + + ) : ( + <> + {subscription.recurly.account.has_past_due_invoice._ !== 'true' && ( + + {t('upgrade_plan')} + + )} + + )} + {!onStandalonePlan && ( + <> + {' '} + + + )} +
      + ) +} + +function FlexibleGroupLicensingActions({ + subscription, +}: { + subscription: RecurlySubscription +}) { + const { t } = useTranslation() + const isProfessionalPlan = subscription.planCode + .toLowerCase() + .includes('professional') + + return ( + <> + {!isProfessionalPlan && ( + <> + + {t('upgrade_plan')} + {' '} + + )} + {subscription.plan.membersLimitAddOn === 'additional-license' && ( + + {t('add_more_users')} + + )} + + ) +} diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/add-ons.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/add-ons.tsx new file mode 100644 index 0000000000..5577fd44f4 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/add-ons.tsx @@ -0,0 +1,144 @@ +import { useTranslation } from 'react-i18next' +import { + Dropdown as BS3Dropdown, + MenuItem as BS3MenuItem, +} from 'react-bootstrap' +import { Dropdown, DropdownMenu, DropdownToggle } from 'react-bootstrap-5' +import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item' +import ControlledDropdown from '@/shared/components/controlled-dropdown' +import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' +import MaterialIcon from '@/shared/components/material-icon' +import { + ADD_ON_NAME, + AI_ADD_ON_CODE, + AI_STANDALONE_ANNUAL_PLAN_CODE, + AI_STANDALONE_PLAN_CODE, +} from '@/features/subscription/data/add-on-codes' +import sparkle from '@/shared/svgs/sparkle.svg' +import { bsVersion } from '@/features/utils/bootstrap-5' +import classnames from 'classnames' +import { RecurlySubscription } from '../../../../../../../../types/subscription/dashboard/subscription' +import { PendingRecurlyPlan } from '../../../../../../../../types/subscription/plan' + +type AddOnsProps = { + subscription: RecurlySubscription + onStandalonePlan: boolean + handleCancelClick: (code: string) => void +} + +function AddOns({ + subscription, + onStandalonePlan, + handleCancelClick, +}: AddOnsProps) { + const { t } = useTranslation() + const addOnsDisplayPrices = + subscription.recurly.addOnDisplayPricesWithoutAdditionalLicense + const addOnsDisplayPricesEntries = Object.entries( + onStandalonePlan + ? { + [AI_STANDALONE_PLAN_CODE]: subscription.recurly.displayPrice, + } + : addOnsDisplayPrices + ) + const pendingPlan = subscription.pendingPlan as PendingRecurlyPlan + const hasAiAddon = subscription.addOns?.some( + addOn => addOn.addOnCode === AI_ADD_ON_CODE + ) + const pendingCancellation = Boolean( + hasAiAddon && + pendingPlan && + !pendingPlan.addOns?.some(addOn => addOn.add_on_code === AI_ADD_ON_CODE) + ) + + const resolveAddOnName = (addOnCode: string) => { + switch (addOnCode) { + case AI_ADD_ON_CODE: + case AI_STANDALONE_ANNUAL_PLAN_CODE: + case AI_STANDALONE_PLAN_CODE: + return ADD_ON_NAME + } + } + + return ( + <> +

      + {t('add_ons')} +

      + {addOnsDisplayPricesEntries.length > 0 ? ( + addOnsDisplayPricesEntries.map(([code, displayPrice]) => ( +
      +
      + +
      +
      +
      {resolveAddOnName(code)}
      +
      + {subscription.plan.annual + ? t('x_price_per_year', { price: displayPrice }) + : t('x_price_per_month', { price: displayPrice })} +
      +
      + {!pendingCancellation && ( +
      + + + + + + handleCancelClick(code)}> + {t('cancel')} + + + + } + bs5={ + + + + + + handleCancelClick(code)} + as="button" + tabIndex={-1} + > + {t('cancel')} + + + + } + /> +
      + )} +
      + )) + ) : ( +

      {t('you_dont_have_any_add_ons_on_your_account')}

      + )} + + ) +} + +export default AddOns diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/trial-ending.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/trial-ending.tsx index 3e7ce439a4..11be8ce3b9 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/trial-ending.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/trial-ending.tsx @@ -1,12 +1,16 @@ import { Trans } from 'react-i18next' +type TrialEndingProps = { + trialEndsAtFormatted: string + className?: string +} + export function TrialEnding({ trialEndsAtFormatted, -}: { - trialEndsAtFormatted: string -}) { + className, +}: TrialEndingProps) { return ( -

      +

      - {t('view_your_invoices')} - +

      diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/expired.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/expired.tsx index 985b73795d..c72306324e 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/expired.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/expired.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next' import { RecurlySubscription } from '../../../../../../../types/subscription/dashboard/subscription' +import OLButton from '@/features/ui/components/ol/ol-button' export function ExpiredSubscription({ subscription, @@ -12,17 +13,18 @@ export function ExpiredSubscription({ <>

      {t('your_subscription_has_expired')}

      - {t('view_your_invoices')} - - + + {t('create_new_subscription')} - +

      ) diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/tag.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/tag.tsx index 8fede7f50d..dda1c9861e 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/tag.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/tag.tsx @@ -38,13 +38,19 @@ const Tag = forwardRef( {contentProps?.onClick ? ( ) : ( - + {content} )} diff --git a/services/web/frontend/js/features/ui/components/ol/ol-tag.tsx b/services/web/frontend/js/features/ui/components/ol/ol-tag.tsx index eeb19c3814..15774b9e41 100644 --- a/services/web/frontend/js/features/ui/components/ol/ol-tag.tsx +++ b/services/web/frontend/js/features/ui/components/ol/ol-tag.tsx @@ -21,6 +21,7 @@ const OLTag = forwardRef((props: OLTagProps, ref) => { onBlur: rest.onBlur, onMouseOver: rest.onMouseOver, onMouseOut: rest.onMouseOut, + contentProps: rest.contentProps, ...getAriaAndDataProps(rest), ...bs3Props, } diff --git a/services/web/frontend/js/pages/user/subscription/group-management/upgrade-group-subscription.tsx b/services/web/frontend/js/pages/user/subscription/group-management/upgrade-group-subscription.tsx index abed1387d2..18abd65543 100644 --- a/services/web/frontend/js/pages/user/subscription/group-management/upgrade-group-subscription.tsx +++ b/services/web/frontend/js/pages/user/subscription/group-management/upgrade-group-subscription.tsx @@ -1,8 +1,8 @@ import '../base' import ReactDOM from 'react-dom' -import UpgradeSubscription from '@/features/group-management/components/upgrade-subscription/upgrade-subscription' +import Root from '@/features/group-management/components/upgrade-subscription/root' const element = document.getElementById('upgrade-group-subscription-root') if (element) { - ReactDOM.render(, element) + ReactDOM.render(, element) } diff --git a/services/web/frontend/js/shared/components/tag.tsx b/services/web/frontend/js/shared/components/tag.tsx index 0f6cd033b1..e704936d0e 100644 --- a/services/web/frontend/js/shared/components/tag.tsx +++ b/services/web/frontend/js/shared/components/tag.tsx @@ -28,7 +28,14 @@ function Tag({ return ( - + {prepend && {prepend}} {children} diff --git a/services/web/frontend/js/shared/svgs/sparkle.svg b/services/web/frontend/js/shared/svgs/sparkle.svg new file mode 100644 index 0000000000..1946609ffa --- /dev/null +++ b/services/web/frontend/js/shared/svgs/sparkle.svg @@ -0,0 +1 @@ + diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 667f2f1fc9..27adcbb39e 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -104,6 +104,7 @@ export interface Meta { 'ol-groupPolicy': GroupPolicy 'ol-groupSSOActive': boolean 'ol-groupSSOTestResult': GroupSSOTestResult + 'ol-groupSettingsAdvertisedFor': string[] 'ol-groupSettingsEnabledFor': string[] 'ol-groupSize': number 'ol-groupSsoSetupSuccess': boolean diff --git a/services/web/frontend/stylesheets/app/subscription.less b/services/web/frontend/stylesheets/app/subscription.less index ee915bc641..24e0dde968 100644 --- a/services/web/frontend/stylesheets/app/subscription.less +++ b/services/web/frontend/stylesheets/app/subscription.less @@ -231,17 +231,21 @@ } } -a.row-link { +.row-link { line-height: 24px; - color: @neutral-70; + color: @neutral-70 !important; display: flex; flex-direction: row; text-decoration: none; padding: 6px 0; - &:active, - &:focus, - &:hover { + &.text-muted { + color: @neutral-40 !important; + } + + a&:active, + a&:focus, + a&:hover { text-decoration: none; outline: none; background-color: @gray-lightest; @@ -268,12 +272,15 @@ a.row-link { flex: 1 1 90%; display: flex; flex-direction: column; + .text-truncate; .heading { font-weight: @btn-font-weight; + .text-truncate; } .subtext { font-weight: 400; + .text-truncate; } } } @@ -306,3 +313,51 @@ a.row-link { margin-top: 0; color: @content-primary-on-dark-bg; } + +.add-on-card { + display: flex; + align-items: center; + padding: var(--spacing-05); + gap: var(--spacing-05); + border: 1px solid var(--neutral-20); + border-radius: var(--border-radius-base-new); + + .add-on-card-icon { + width: 40px; + height: 40px; + } + + .add-on-card-content { + display: flex; + flex-direction: column; + + .small { + .body-sm; + } + } + + .heading { + .body-base; + font-weight: 600; + } + + .description { + color: @content-secondary; + } + + .highlight { + color: @content-primary; + } + + .add-on-options-toggle { + padding: var(--spacing-04); + font-size: 0; + line-height: 1; + border: none; + border-radius: 50%; + + &::after { + content: none; + } + } +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/badge.scss b/services/web/frontend/stylesheets/bootstrap-5/components/badge.scss index 2bd39295fd..d3b5b812d9 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/badge.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/badge.scss @@ -78,6 +78,11 @@ $max-width: 160px; padding-right: var(--bs-badge-padding-x); border-top-left-radius: inherit; border-bottom-left-radius: inherit; + + &:last-child { + border-top-right-radius: inherit; + border-bottom-right-radius: inherit; + } } .badge-tag-content-btn { diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss index c1b3ee8115..2c0b75947d 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss @@ -15,7 +15,7 @@ border: 0; padding: 0; - a { + .row-link-inner { display: flex; justify-content: space-between; align-items: center; @@ -23,7 +23,9 @@ text-decoration: none; color: var(--neutral-90); width: 100%; + } + a.row-link-inner { &:hover { background-color: var(--neutral-10); } @@ -437,3 +439,48 @@ } } } + +.add-on-card { + display: flex; + align-items: center; + padding: var(--spacing-05); + gap: var(--spacing-05); + border: 1px solid var(--border-divider); + border-radius: var(--border-radius-base); + + .add-on-card-icon { + width: 40px; + height: 40px; + } + + .add-on-card-content { + display: flex; + flex-direction: column; + } + + .heading { + @include body-base; + + font-weight: 600; + } + + .description { + color: var(--content-secondary); + } + + .highlight { + color: var(--content-primary); + } + + .add-on-options-toggle { + padding: var(--spacing-04); + font-size: 0; + line-height: 1; + border: none; + border-radius: 50%; + + &::after { + content: none; + } + } +} diff --git a/services/web/frontend/stylesheets/components/badge.less b/services/web/frontend/stylesheets/components/badge.less index f6c7bfe487..abf18097aa 100644 --- a/services/web/frontend/stylesheets/components/badge.less +++ b/services/web/frontend/stylesheets/components/badge.less @@ -27,6 +27,7 @@ &-prepend { margin-right: 2px; + display: flex; } &-close { diff --git a/services/web/frontend/stylesheets/components/group-members.less b/services/web/frontend/stylesheets/components/group-members.less index 4693267f3a..6dc2b9dc41 100644 --- a/services/web/frontend/stylesheets/components/group-members.less +++ b/services/web/frontend/stylesheets/components/group-members.less @@ -204,3 +204,8 @@ top: 4px; } } + +.badge-group-settings { + align-self: center; + padding: 0 16px; +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 308816c667..f60f412029 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -92,6 +92,7 @@ "add_more_users_to_my_plan": "Add more users to my plan", "add_new_email": "Add new email", "add_on": "Add-on", + "add_ons": "Add-ons", "add_ons_are": "Add-ons: __addOnName__", "add_or_remove_project_from_tag": "Add or remove project from tag __tagName__", "add_people": "Add people", @@ -194,6 +195,7 @@ "autocomplete": "Autocomplete", "autocomplete_references": "Reference Autocomplete (inside a \\cite{} block)", "automatic_user_registration_uppercase": "Automatic user registration", + "available_with_group_professional": "Available with Group Professional", "back": "Back", "back_to_account_settings": "Back to account settings", "back_to_all_posts": "Back to all posts", @@ -216,7 +218,10 @@ "beta_program_opt_out_action": "Opt-Out of Beta Program", "bibliographies": "Bibliographies", "billed_annually": "billed annually", + "billed_annually_at": "Billed annually at <0>__price__ <1>(includes plan and any add-ons)", + "billed_monthly_at": "Billed monthly at <0>__price__ <1>(includes plan and any add-ons)", "billed_yearly": "billed yearly", + "billing": "Billing", "billing_period_sentence_case": "Billing period", "binary_history_error": "Preview not available for this file type", "blank_project": "Blank Project", @@ -782,6 +787,7 @@ "get_involved": "Get involved", "get_more_compile_time": "Get more compile time", "get_most_subscription_by_checking_features": "Get the most out of your __appName__ subscription by checking out <0>__appName__’s features.", + "get_most_subscription_discover_premium_features": "Get the most from your __appName__ subscription. <0>Discover premium features.", "get_symbol_palette": "Get Symbol Palette", "get_the_best_overleaf_experience": "Get the best Overleaf experience", "get_the_best_writing_experience": "Get the best writing experience", @@ -857,11 +863,15 @@ "group_invite_has_been_sent_to_email": "Group invite has been sent to <0>__email__", "group_libraries": "Group Libraries", "group_managed_by_group_administrator": "User accounts in this group are managed by the group administrator.", + "group_management": "Group management", + "group_managers": "Group managers", + "group_members": "Group members", "group_plan_admins_can_easily_add_and_remove_users_from_a_group": "Group plan admins can easily add and remove users from a group. For site-wide plans, users are automatically upgraded when they register or add their email address to Overleaf (domain-based enrollment or SSO).", "group_plan_tooltip": "You are on the __plan__ plan as a member of a group subscription. Click to find out how to make the most of your Overleaf premium features.", "group_plan_upgrade_description": "You’re on the <0>__currentPlan__ plan and you’re upgrading to the <0>__nextPlan__ plan. If you’re interested in a site-wide Overleaf Commons plan, please <1>get in touch.", "group_plan_with_name_tooltip": "You are on the __plan__ plan as a member of a group subscription, __groupName__. Click to find out how to make the most of your Overleaf premium features.", "group_professional": "Group Professional", + "group_settings": "Group settings", "group_sso_configuration_idp_metadata": "The information you provide here comes from your Identity Provider (IdP). This is often referred to as its <0>SAML metadata. You can add this manually or click <1>Import IdP metadata to import an XML file.", "group_sso_configure_service_provider_in_idp": "For some IdPs, you must configure Overleaf as a Service Provider to get the data you need to fill out this form. To do this, you will need to download the Overleaf metadata.", "group_sso_documentation_links": "Please see our <0>documentation and <1>troubleshooting guide for more help.", @@ -1728,6 +1738,7 @@ "rename": "Rename", "rename_project": "Rename Project", "renaming": "Renaming", + "renews_on": "Renews on <0>__date__", "reopen": "Re-open", "reopen_comment_error_message": "There was an error reopening your comment. Please try again in a few moments.", "reopen_comment_error_title": "Reopen Comment Error", @@ -2060,6 +2071,8 @@ "suggestion_applied": "Suggestion applied", "support": "Support", "support_for_your_browser_is_ending_soon": "Support for your browser is ending soon", + "supports_up_to_x_users": "Supports up to <0>__count__ users", + "supports_up_to_x_users_incl_y_additional_licenses": "Supports up to <0>__count__ users (Incl. <1>__additionalLicenses__ additional license(s))", "sure_you_want_to_cancel_plan_change": "Are you sure you want to revert your scheduled plan change? You will remain subscribed to the <0>__planName__ plan.", "sure_you_want_to_change_plan": "Are you sure you want to change plan to <0>__planName__?", "sure_you_want_to_delete": "Are you sure you want to permanently delete the following files?", @@ -2356,6 +2369,7 @@ "upgrade_for_12x_more_compile_time": "Upgrade to get 12x more compile time", "upgrade_my_plan": "Upgrade my plan", "upgrade_now": "Upgrade now", + "upgrade_plan": "Upgrade plan", "upgrade_summary": "Upgrade summary", "upgrade_to_add_more_editors_and_access_collaboration_features": "Upgrade to add more editors and access collaboration features like track changes and full project history.", "upgrade_to_get_feature": "Upgrade to get __feature__, plus:", @@ -2369,6 +2383,7 @@ "url_to_fetch_the_file_from": "URL to fetch the file from", "us_gov_banner_government_purchasing": "<0>Get __appName__ for US federal government. Move faster through procurement with our tailored purchasing options. Talk to our government team.", "us_gov_banner_small_business_reseller": "<0>Easy procurement for US federal government. We partner with small business resellers to help you buy Overleaf organizational plans. Talk to our government team.", + "usage_metrics": "Usage metrics", "use_a_different_password": "Please use a different password", "use_saml_metadata_to_configure_sso_with_idp": "Use the Overleaf SAML metadata to configure SSO with your Identity Provider.", "use_your_own_machine": "Use your own machine, with your own setup", @@ -2405,6 +2420,7 @@ "verify_email_address_before_enabling_managed_users": "You need to verify your email address before enabling managed users.", "view": "View", "view_all": "View All", + "view_billing_details": "View billing details", "view_code": "View code", "view_configuration": "View configuration", "view_group_members": "View group members", @@ -2487,6 +2503,7 @@ "x_price_for_first_month": "<0>__price__ for your first month", "x_price_for_first_year": "<0>__price__ for your first year", "x_price_for_y_months": "<0>__price__ for your first __discountMonths__ months", + "x_price_per_month": "__price__ per month", "x_price_per_user": "__price__ per user", "x_price_per_year": "__price__ per year", "year": "year", @@ -2519,6 +2536,7 @@ "you_cant_add_or_change_password_due_to_sso": "You can’t add or change your password because your group or organization uses <0>single sign-on (SSO).", "you_cant_join_this_group_subscription": "You can’t join this group subscription", "you_cant_reset_password_due_to_sso": "You can’t reset your password because your group or organization uses SSO. <0>Log in with SSO.", + "you_dont_have_any_add_ons_on_your_account": "You don’t have any add-ons on your account.", "you_dont_have_any_repositories": "You don’t have any repositories", "you_have_0_free_suggestions_left": "You have 0 free suggestions left", "you_have_1_free_suggestion_left": "You have 1 free suggestion left", diff --git a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts index 9bdde3f181..24194ea080 100644 --- a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts +++ b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts @@ -54,6 +54,9 @@ export const annualActiveSubscription: RecurlySubscription = { has_past_due_invoice: { _: 'false', $: { type: 'boolean' } }, }, displayPrice: '$199.00', + planOnlyDisplayPrice: '', + addOns: [], + addOnDisplayPricesWithoutAdditionalLicense: {}, }, } @@ -96,6 +99,9 @@ export const annualActiveSubscriptionEuro: RecurlySubscription = { has_past_due_invoice: { _: 'false', $: { type: 'boolean' } }, }, displayPrice: '€221.96', + planOnlyDisplayPrice: '', + addOns: [], + addOnDisplayPricesWithoutAdditionalLicense: {}, }, } @@ -137,6 +143,9 @@ export const annualActiveSubscriptionPro: RecurlySubscription = { has_past_due_invoice: { _: 'false', $: { type: 'boolean' } }, }, displayPrice: '$42.00', + planOnlyDisplayPrice: '', + addOns: [], + addOnDisplayPricesWithoutAdditionalLicense: {}, }, } @@ -179,6 +188,9 @@ export const pastDueExpiredSubscription: RecurlySubscription = { has_past_due_invoice: { _: 'true', $: { type: 'boolean' } }, }, displayPrice: '$199.00', + planOnlyDisplayPrice: '', + addOns: [], + addOnDisplayPricesWithoutAdditionalLicense: {}, }, } @@ -221,6 +233,9 @@ export const canceledSubscription: RecurlySubscription = { has_past_due_invoice: { _: 'false', $: { type: 'boolean' } }, }, displayPrice: '$199.00', + planOnlyDisplayPrice: '', + addOns: [], + addOnDisplayPricesWithoutAdditionalLicense: {}, }, } @@ -263,6 +278,9 @@ export const pendingSubscriptionChange: RecurlySubscription = { has_past_due_invoice: { _: 'false', $: { type: 'boolean' } }, }, displayPrice: '$199.00', + planOnlyDisplayPrice: '', + addOns: [], + addOnDisplayPricesWithoutAdditionalLicense: {}, }, pendingPlan: { planCode: 'professional-annual', @@ -316,6 +334,9 @@ export const groupActiveSubscription: GroupSubscription = { has_past_due_invoice: { _: 'false', $: { type: 'boolean' } }, }, displayPrice: '$1290.00', + planOnlyDisplayPrice: '', + addOns: [], + addOnDisplayPricesWithoutAdditionalLicense: {}, }, } @@ -376,6 +397,9 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription currentPlanDisplayPrice: '$2709.00', pendingAdditionalLicenses: 13, pendingTotalLicenses: 23, + planOnlyDisplayPrice: '', + addOns: [], + addOnDisplayPricesWithoutAdditionalLicense: {}, }, pendingPlan: { planCode: 'group_collaborator_10_enterprise', @@ -438,6 +462,9 @@ export const trialSubscription: RecurlySubscription = { }, }, displayPrice: '$14.00', + planOnlyDisplayPrice: '', + addOns: [], + addOnDisplayPricesWithoutAdditionalLicense: {}, }, } @@ -511,6 +538,9 @@ export const trialCollaboratorSubscription: RecurlySubscription = { }, }, displayPrice: '$21.00', + planOnlyDisplayPrice: '', + addOns: [], + addOnDisplayPricesWithoutAdditionalLicense: {}, }, } @@ -552,5 +582,8 @@ export const monthlyActiveCollaborator: RecurlySubscription = { has_past_due_invoice: { _: 'false', $: { type: 'boolean' } }, }, displayPrice: '$21.00', + planOnlyDisplayPrice: '', + addOns: [], + addOnDisplayPricesWithoutAdditionalLicense: {}, }, } diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs index 04b961af2b..3511d0bc39 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs @@ -56,6 +56,7 @@ describe('SubscriptionGroupController', function () { .stub() .resolves(this.createSubscriptionChangeData), ensureFlexibleLicensingEnabled: sinon.stub().resolves(), + ensureSubscriptionIsActive: sinon.stub().resolves(), getGroupPlanUpgradePreview: sinon .stub() .resolves(this.previewSubscriptionChangeData), @@ -347,6 +348,9 @@ describe('SubscriptionGroupController', function () { this.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled .calledWith(this.plan) .should.equal(true) + this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive + .calledWith(this.subscription) + .should.equal(true) page.should.equal('subscriptions/add-seats') props.subscriptionId.should.equal(this.subscriptionId) props.groupName.should.equal(this.subscription.teamName) @@ -403,6 +407,21 @@ describe('SubscriptionGroupController', function () { this.Controller.addSeatsToGroupSubscription(this.req, res) }) + + it('should redirect to subscription page when subscription is not active', function (done) { + this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive = sinon + .stub() + .rejects() + + const res = { + redirect: url => { + url.should.equal('/user/subscription') + done() + }, + } + + this.Controller.addSeatsToGroupSubscription(this.req, res) + }) }) describe('previewAddSeatsSubscriptionChange', function () { diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js index 9825cadced..c2c1102b8e 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js @@ -59,7 +59,12 @@ describe('SubscriptionGroupHandler', function () { this.SubscriptionLocator = { promises: { - getUsersSubscription: sinon.stub().resolves({ groupPlan: true }), + getUsersSubscription: sinon.stub().resolves({ + groupPlan: true, + recurlyStatus: { + state: 'active', + }, + }), getSubscriptionByMemberIdAndId: sinon.stub(), getSubscription: sinon.stub().resolves(this.subscription), }, @@ -303,7 +308,12 @@ describe('SubscriptionGroupHandler', function () { expect(data).to.deep.equal({ userId: this.adminUser_id, - subscription: { groupPlan: true }, + subscription: { + groupPlan: true, + recurlyStatus: { + state: 'active', + }, + }, plan: { membersLimit: 5, membersLimitAddOn: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE, @@ -517,11 +527,33 @@ describe('SubscriptionGroupHandler', function () { }) }) + describe('ensureSubscriptionIsActive', function () { + it('should throw if the subscription is not active', async function () { + await expect( + this.Handler.promises.ensureSubscriptionIsActive({}) + ).to.be.rejectedWith('The subscription is not active') + }) + + it('should not throw if the subscription is active', async function () { + await expect( + this.Handler.promises.ensureSubscriptionIsActive({ + recurlyStatus: { state: 'active' }, + }) + ).to.not.be.rejected + }) + }) + describe('upgradeGroupPlan', function () { it('should upgrade the subscription for flexible licensing group plans', async function () { this.SubscriptionLocator.promises.getUsersSubscription = sinon .stub() - .resolves({ groupPlan: true, planCode: 'group_collaborator' }) + .resolves({ + groupPlan: true, + recurlyStatus: { + state: 'active', + }, + planCode: 'group_collaborator', + }) await this.Handler.promises.upgradeGroupPlan(this.user_id) this.recurlySubscription.getRequestForGroupPlanUpgrade .calledWith('group_professional') @@ -539,6 +571,9 @@ describe('SubscriptionGroupHandler', function () { .stub() .resolves({ groupPlan: true, + recurlyStatus: { + state: 'active', + }, planCode: 'group_collaborator_10_educational', }) await this.Handler.promises.upgradeGroupPlan(this.user_id) @@ -556,7 +591,13 @@ describe('SubscriptionGroupHandler', function () { it('should fail the upgrade if is professional already', async function () { this.SubscriptionLocator.promises.getUsersSubscription = sinon .stub() - .resolves({ groupPlan: true, planCode: 'group_professional' }) + .resolves({ + groupPlan: true, + recurlyStatus: { + state: 'active', + }, + planCode: 'group_professional', + }) await expect( this.Handler.promises.upgradeGroupPlan(this.user_id) ).to.be.rejectedWith('Not eligible for group plan upgrade') @@ -565,7 +606,13 @@ describe('SubscriptionGroupHandler', function () { it('should fail the upgrade if not group plan', async function () { this.SubscriptionLocator.promises.getUsersSubscription = sinon .stub() - .resolves({ groupPlan: false, planCode: 'test_plan_code' }) + .resolves({ + groupPlan: false, + recurlyStatus: { + state: 'active', + }, + planCode: 'test_plan_code', + }) await expect( this.Handler.promises.upgradeGroupPlan(this.user_id) ).to.be.rejectedWith('Not eligible for group plan upgrade') @@ -576,7 +623,13 @@ describe('SubscriptionGroupHandler', function () { it('should generate preview for subscription upgrade', async function () { this.SubscriptionLocator.promises.getUsersSubscription = sinon .stub() - .resolves({ groupPlan: true, planCode: 'group_collaborator' }) + .resolves({ + groupPlan: true, + recurlyStatus: { + state: 'active', + }, + planCode: 'group_collaborator', + }) const result = await this.Handler.promises.getGroupPlanUpgradePreview( this.user_id ) diff --git a/services/web/types/subscription/dashboard/subscription.ts b/services/web/types/subscription/dashboard/subscription.ts index c4ba1991de..c67f249c4c 100644 --- a/services/web/types/subscription/dashboard/subscription.ts +++ b/services/web/types/subscription/dashboard/subscription.ts @@ -1,6 +1,6 @@ import { CurrencyCode } from '../currency' import { Nullable } from '../../utils' -import { Plan, AddOn } from '../plan' +import { Plan, AddOn, RecurlyAddOn } from '../plan' import { User } from '../../user' type SubscriptionState = 'active' | 'canceled' | 'expired' | 'paused' @@ -16,6 +16,7 @@ type Recurly = { billingDetailsLink: string accountManagementLink: string additionalLicenses: number + addOns: RecurlyAddOn[] totalLicenses: number nextPaymentDueAt: string nextPaymentDueDate: string @@ -42,6 +43,8 @@ type Recurly = { } } displayPrice: string + planOnlyDisplayPrice: string + addOnDisplayPricesWithoutAdditionalLicense: Record currentPlanDisplayPrice?: string pendingAdditionalLicenses?: number pendingTotalLicenses?: number diff --git a/services/web/types/subscription/plan.ts b/services/web/types/subscription/plan.ts index 1afb7a567b..e97a354b5a 100644 --- a/services/web/types/subscription/plan.ts +++ b/services/web/types/subscription/plan.ts @@ -27,6 +27,7 @@ export type RecurlyAddOn = { add_on_code: string quantity: number unit_amount_in_cents: number + displayPrice: string } export type PendingRecurlyPlan = {