Merge pull request #23117 from overleaf/ii-flexible-group-licensing-subscription-page

[web] Subscription page for flexible licensing

GitOrigin-RevId: 8f2fab1fc01e27063d716a86add66b1b9a72cbe6
This commit is contained in:
ilkin-overleaf
2025-02-04 14:36:27 +02:00
committed by Copybot
parent 54e439c293
commit f166e9263c
36 changed files with 1060 additions and 67 deletions

View File

@@ -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,

View File

@@ -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 || []),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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": "",

View File

@@ -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 (
<SplitTestProvider>
<AddSeats />
</SplitTestProvider>
)
return <AddSeats />
}
export default Root

View File

@@ -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 <UpgradeSubscription />
}
export default Root

View File

@@ -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 (
<OLTag
prepend={
<img
aria-hidden="true"
src="/img/material-icons/star-gradient.svg"
alt=""
/>
}
contentProps={{
className: bsVersion({ bs5: 'mw-100' }),
onClick: handleUpgradeClick,
}}
>
<strong>{t('available_with_group_professional')}</strong>
</OLTag>
)
}
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 (
<RowLink
href={`/manage/groups/${subscription._id}/settings`}
heading={t('manage_group_settings')}
heading={heading}
subtext={groupSettingRowSubText}
icon="settings"
/>
)
}
export function GroupSettingsButtonWithAdBadge({
subscription,
}: {
subscription: ManagedGroupSubscription
}) {
const { heading, groupSettingRowSubText } =
useGroupSettingsButton(subscription)
return (
<BootstrapVersionSwitcher
bs3={
<div className="row-link text-muted">
<div className="icon">
<MaterialIcon type="settings" />
</div>
<div className="text">
<div className="heading">{heading}</div>
<div className="subtext">{groupSettingRowSubText}</div>
</div>
<span className="badge-group-settings">
<AvailableWithGroupProfessionalBadge />
</span>
</div>
}
bs5={
<li className="list-group-item row-link">
<div className="row-link-inner">
<MaterialIcon type="settings" className="p-2 p-md-3 text-muted" />
<div className="flex-grow-1 text-truncate text-muted">
<strong>{heading}</strong>
<div className="text-truncate">{groupSettingRowSubText}</div>
</div>
<span className="p-2 p-md-3">
<AvailableWithGroupProfessionalBadge />
</span>
</div>
</li>
}
/>
)
}

View File

@@ -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 (
<div key={`managed-group-${subscription._id}`}>
<h2 className={classnames('h3', bsVersion({ bs5: 'fw-bold' }))}>
{t('group_management')}
</h2>
<p>
<ManagedGroupAdministrator subscription={subscription} />
</p>
<ul className="list-group p-0">
<RowLink
href={`/manage/groups/${subscription._id}/members`}
heading={t('manage_members')}
heading={
isFlexibleGroupLicensing
? t('group_members')
: t('manage_members')
}
subtext={t('manage_group_members_subtext')}
icon="groups"
/>
<RowLink
href={`/manage/groups/${subscription._id}/managers`}
heading={t('manage_group_managers')}
heading={
isFlexibleGroupLicensing
? t('group_managers')
: t('manage_group_managers')
}
subtext={t('manage_managers_subtext')}
icon="manage_accounts"
/>
{groupSettingsEnabledFor?.includes(subscription._id) && (
<GroupSettingsButton subscription={subscription} />
)}
{groupSettingsAdvertisedFor?.includes(subscription._id) && (
<GroupSettingsButtonWithAdBadge subscription={subscription} />
)}
<RowLink
href={`/metrics/groups/${subscription._id}`}
heading={t('view_metrics')}
heading={
isFlexibleGroupLicensing
? t('usage_metrics')
: t('view_metrics')
}
subtext={t('view_metrics_group_subtext')}
icon="insights"
/>

View File

@@ -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 <ActiveSubscriptionNew subscription={subscription} />
} else if (state === 'active' && planHasAi) {
return <ActiveAiAddonSubscription subscription={subscription} />
} else if (state === 'active') {
return <ActiveSubscription subscription={subscription} />

View File

@@ -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
<a href="/about/features-overview" />
)
const isFlexibleGroupLicensing = useFeatureFlag('flexible-group-licensing')
return (
<p>
<Trans
i18nKey="get_most_subscription_by_checking_features"
components={[featuresPageLink]}
/>
{isFlexibleGroupLicensing ? (
<Trans
i18nKey="get_most_subscription_discover_premium_features"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a href="/learn/how-to/Overleaf_premium_features" />,
]}
/>
) : (
<Trans
i18nKey="get_most_subscription_by_checking_features"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a href="/about/features-overview" />,
]}
/>
)}
</p>
)
}

View File

@@ -32,7 +32,7 @@ function BS3RowLink({ href, heading, subtext, icon }: RowLinkProps) {
function BS5RowLink({ href, heading, subtext, icon }: RowLinkProps) {
return (
<li className="list-group-item row-link">
<a href={href}>
<a href={href} className="row-link-inner">
<MaterialIcon type={icon} className="p-2 p-md-3" />
<div className="flex-grow-1">
<strong>{heading}</strong>

View File

@@ -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 <CancelSubscription />
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 (
<>
<h2 className={classnames('h3', bsVersion({ bs5: 'fw-bold' }))}>
{t('billing')}
</h2>
<p className="mb-1">
{subscription.plan.annual ? (
<Trans
i18nKey="billed_annually_at"
values={{ price: subscription.recurly.displayPrice }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
// eslint-disable-next-line react/jsx-key
<i />,
]}
/>
) : (
<Trans
i18nKey="billed_monthly_at"
values={{ price: subscription.recurly.displayPrice }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
// eslint-disable-next-line react/jsx-key
<i />,
]}
/>
)}
</p>
<p className="mb-1">
<Trans
i18nKey="renews_on"
values={{ date: subscription.recurly.nextPaymentDueDate }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[<strong />]} // eslint-disable-line react/jsx-key
/>
</p>
<div>
<a
href={subscription.recurly.accountManagementLink}
target="_blank"
rel="noreferrer noopener"
className="me-2"
>
{t('view_invoices')}
</a>
<a
href={subscription.recurly.billingDetailsLink}
target="_blank"
rel="noreferrer noopener"
>
{t('view_billing_details')}
</a>
</div>
<div className="mt-3">
<PriceExceptions subscription={subscription} />
{!recurlyLoadError && (
<p>
<i>
<SubscriptionRemainder subscription={subscription} hideTime />
</i>
</p>
)}
</div>
<hr />
<h2 className={classnames('h3', bsVersion({ bs5: 'fw-bold' }))}>
{t('plan')}
</h2>
<h3 className={classnames('h5 mt-0 mb-1', bsVersion({ bs5: 'fw-bold' }))}>
{planName}
</h3>
<p className="mb-1">
{subscription.pendingPlan && (
<>
{' '}
<PendingPlanChange subscription={subscription} />
</>
)}
</p>
{subscription.pendingPlan &&
subscription.pendingPlan.name !== subscription.plan.name && (
<p className="mb-1">{t('want_change_to_apply_before_plan_end')}</p>
)}
{isInFreeTrial(subscription.recurly.trial_ends_at) &&
subscription.recurly.trialEndsAtFormatted && (
<TrialEnding
trialEndsAtFormatted={subscription.recurly.trialEndsAtFormatted}
className="mb-1"
/>
)}
{!subscription.pendingPlan && subscription.recurly.totalLicenses > 0 && (
<p className="mb-1">
{isLegacyPlan && subscription.recurly.additionalLicenses > 0 ? (
<Trans
i18nKey="supports_up_to_x_users_incl_y_additional_licenses"
values={{
count: subscription.recurly.totalLicenses,
additionalLicenses: subscription.recurly.additionalLicenses,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[<strong />, <strong />]} // eslint-disable-line react/jsx-key
/>
) : (
<Trans
i18nKey="supports_up_to_x_users"
values={{ count: subscription.recurly.totalLicenses }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[<strong />]} // eslint-disable-line react/jsx-key
/>
)}
</p>
)}
{!onStandalonePlan && (
<p className="mb-1">
{subscription.plan.annual
? t('x_price_per_year', {
price: subscription.recurly.planOnlyDisplayPrice,
})
: t('x_price_per_month', {
price: subscription.recurly.planOnlyDisplayPrice,
})}
</p>
)}
{!recurlyLoadError && (
<PlanActions
subscription={subscription}
onStandalonePlan={onStandalonePlan}
handlePlanChange={handlePlanChange}
/>
)}
<hr />
<AddOns
subscription={subscription}
onStandalonePlan={onStandalonePlan}
handleCancelClick={handleCancelClick}
/>
<ChangePlanModal />
<ConfirmChangePlanModal />
<KeepCurrentPlanModal />
<ChangeToGroupModal />
<CancelAiAddOnModal />
</>
)
}
type PlanActionsProps = {
subscription: RecurlySubscription
onStandalonePlan: boolean
handlePlanChange: () => void
}
function PlanActions({
subscription,
onStandalonePlan,
handlePlanChange,
}: PlanActionsProps) {
const { t } = useTranslation()
const isSubscriptionEligibleForFlexibleGroupLicensing = getMeta(
'ol-canUseFlexibleLicensing'
)
return (
<div className="mt-3">
{isSubscriptionEligibleForFlexibleGroupLicensing ? (
<FlexibleGroupLicensingActions subscription={subscription} />
) : (
<>
{subscription.recurly.account.has_past_due_invoice._ !== 'true' && (
<OLButton variant="secondary" onClick={handlePlanChange}>
{t('upgrade_plan')}
</OLButton>
)}
</>
)}
{!onStandalonePlan && (
<>
{' '}
<CancelSubscriptionButton />
</>
)}
</div>
)
}
function FlexibleGroupLicensingActions({
subscription,
}: {
subscription: RecurlySubscription
}) {
const { t } = useTranslation()
const isProfessionalPlan = subscription.planCode
.toLowerCase()
.includes('professional')
return (
<>
{!isProfessionalPlan && (
<>
<OLButton
variant="secondary"
href="/user/subscription/group/upgrade-subscription"
>
{t('upgrade_plan')}
</OLButton>{' '}
</>
)}
{subscription.plan.membersLimitAddOn === 'additional-license' && (
<OLButton variant="secondary" href="/user/subscription/group/add-users">
{t('add_more_users')}
</OLButton>
)}
</>
)
}

View File

@@ -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 (
<>
<h2 className={classnames('h3', bsVersion({ bs5: 'fw-bold' }))}>
{t('add_ons')}
</h2>
{addOnsDisplayPricesEntries.length > 0 ? (
addOnsDisplayPricesEntries.map(([code, displayPrice]) => (
<div className="add-on-card" key={code}>
<div>
<img
alt="sparkle"
className="add-on-card-icon"
src={sparkle}
aria-hidden="true"
/>
</div>
<div className="add-on-card-content">
<div className="heading">{resolveAddOnName(code)}</div>
<div className="description small mt-1">
{subscription.plan.annual
? t('x_price_per_year', { price: displayPrice })
: t('x_price_per_month', { price: displayPrice })}
</div>
</div>
{!pendingCancellation && (
<div className="ms-auto">
<BootstrapVersionSwitcher
bs3={
<ControlledDropdown id="add-ons-actions" pullRight>
<BS3Dropdown.Toggle
noCaret
bsStyle={null}
className="add-on-options-toggle btn-secondary"
>
<MaterialIcon
type="more_vert"
accessibilityLabel={t('more_options')}
/>
</BS3Dropdown.Toggle>
<BS3Dropdown.Menu>
<BS3MenuItem onClick={() => handleCancelClick(code)}>
{t('cancel')}
</BS3MenuItem>
</BS3Dropdown.Menu>
</ControlledDropdown>
}
bs5={
<Dropdown align="end">
<DropdownToggle
id="add-on-dropdown-toggle"
className="add-on-options-toggle"
variant="secondary"
>
<MaterialIcon
type="more_vert"
accessibilityLabel={t('more_options')}
/>
</DropdownToggle>
<DropdownMenu flip={false}>
<OLDropdownMenuItem
onClick={() => handleCancelClick(code)}
as="button"
tabIndex={-1}
>
{t('cancel')}
</OLDropdownMenuItem>
</DropdownMenu>
</Dropdown>
}
/>
</div>
)}
</div>
))
) : (
<p>{t('you_dont_have_any_add_ons_on_your_account')}</p>
)}
</>
)
}
export default AddOns

View File

@@ -1,12 +1,16 @@
import { Trans } from 'react-i18next'
type TrialEndingProps = {
trialEndsAtFormatted: string
className?: string
}
export function TrialEnding({
trialEndsAtFormatted,
}: {
trialEndsAtFormatted: string
}) {
className,
}: TrialEndingProps) {
return (
<p>
<p className={className}>
<Trans
i18nKey="youre_on_free_trial_which_ends_on"
values={{ date: trialEndsAtFormatted }}

View File

@@ -1,6 +1,7 @@
import { useTranslation, Trans } from 'react-i18next'
import { RecurlySubscription } from '../../../../../../../types/subscription/dashboard/subscription'
import ReactivateSubscription from '../reactivate-subscription'
import OLButton from '@/features/ui/components/ol/ol-button'
export function CanceledSubscription({
subscription,
@@ -40,14 +41,14 @@ export function CanceledSubscription({
/>
</p>
<p>
<a
<OLButton
href={subscription.recurly.accountManagementLink}
target="_blank"
variant="secondary"
rel="noopener noreferrer"
className="btn btn-secondary-info btn-secondary"
>
{t('view_your_invoices')}
</a>
</OLButton>
</p>
<ReactivateSubscription />
</>

View File

@@ -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({
<>
<p>{t('your_subscription_has_expired')}</p>
<p>
<a
<OLButton
href={subscription.recurly.accountManagementLink}
className="btn btn-secondary-info btn-secondary me-1"
target="_blank"
rel="noreferrer noopener"
variant="secondary"
className="me-1"
>
{t('view_your_invoices')}
</a>
<a href="/user/subscription/plans" className="btn btn-primary">
</OLButton>
<OLButton href="/user/subscription/plans" variant="primary">
{t('create_new_subscription')}
</a>
</OLButton>
</p>
</>
)

View File

@@ -38,13 +38,19 @@ const Tag = forwardRef<HTMLElement, TagProps>(
{contentProps?.onClick ? (
<button
type="button"
className="badge-tag-content badge-tag-content-btn"
{...contentProps}
className={classnames(
'badge-tag-content badge-tag-content-btn',
contentProps.className
)}
>
{content}
</button>
) : (
<span className="badge-tag-content" {...contentProps}>
<span
{...contentProps}
className={classnames('badge-tag-content', contentProps?.className)}
>
{content}
</span>
)}

View File

@@ -21,6 +21,7 @@ const OLTag = forwardRef<HTMLElement, OLTagProps>((props: OLTagProps, ref) => {
onBlur: rest.onBlur,
onMouseOver: rest.onMouseOver,
onMouseOut: rest.onMouseOut,
contentProps: rest.contentProps,
...getAriaAndDataProps(rest),
...bs3Props,
}

View File

@@ -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(<UpgradeSubscription />, element)
ReactDOM.render(<Root />, element)
}

View File

@@ -28,7 +28,14 @@ function Tag({
return (
<span className={classnames('badge-tag-bs3', className)} {...rest}>
<span className="badge-tag-bs3-content-wrapper">
<span
{...contentProps}
className={classnames(
'badge-tag-bs3-content-wrapper',
{ clickable: Boolean(contentProps?.onClick) },
contentProps?.className
)}
>
{prepend && <span className="badge-tag-bs3-prepend">{prepend}</span>}
<span className="badge-tag-bs3-content">{children}</span>
</span>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" fill="none"><g clip-path="url(#a)"><path fill="url(#b)" d="M29.7 16.9c0-.2-.3-.2-.4-.3-.9 0-8.1-.3-8.1-8.7 0-.3-.2-.5-.5-.5-.2 0-.5.2-.5.5a8 8 0 0 1-8 8.7l-.3.1-.2.2v.5l.5.2c.9 0 8 .3 8 8.7 0 .3.3.5.5.5.3 0 .5-.2.5-.5a8 8 0 0 1 8-8.7c.3 0 .4 0 .5-.2v-.5Z"/><path fill="url(#c)" d="M10.4 22.9c-.4 0-4.3-.2-4.3-4.8 0-.2-.3-.4-.5-.4-.3 0-.5.2-.5.4 0 4.6-4 4.8-4.4 4.8-.3 0-.5.2-.5.5 0 .2.2.4.5.4.5 0 4.4.2 4.4 4.8 0 .3.2.5.5.5.2 0 .4-.2.4-.5C6 24 10 24 10.4 24c.3 0 .5-.2.5-.5s-.2-.5-.5-.5Z"/><path fill="url(#d)" d="M14.7 7.4c.2 0 .4-.3.4-.6 0-.2-.2-.4-.4-.4-.5 0-4.4-.2-4.4-4.8 0-.3-.3-.5-.5-.5-.3 0-.5.2-.5.5 0 4.6-4 4.8-4.4 4.8-.3 0-.5.2-.5.4 0 .3.3.5.5.5.5 0 4.4.2 4.4 4.8 0 .2.2.5.5.5.2 0 .4-.2.4-.5 0-4.6 4-4.7 4.4-4.7Z"/></g><defs><linearGradient id="b" x1="29.8" x2="8.2" y1="7.4" y2="16.6" gradientUnits="userSpaceOnUse"><stop stop-color="#214475"/><stop offset=".3" stop-color="#254C84"/><stop offset="1" stop-color="#6597E0"/></linearGradient><linearGradient id="c" x1="10.9" x2="-1.8" y1="17.7" y2="23" gradientUnits="userSpaceOnUse"><stop stop-color="#214475"/><stop offset=".3" stop-color="#254C84"/><stop offset="1" stop-color="#6597E0"/></linearGradient><linearGradient id="d" x1="15.1" x2="2.4" y1="1.1" y2="6.5" gradientUnits="userSpaceOnUse"><stop stop-color="#214475"/><stop offset=".3" stop-color="#254C84"/><stop offset="1" stop-color="#6597E0"/></linearGradient><clipPath id="a"><path fill="#fff" d="M0 .9h30v28.2H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -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

View File

@@ -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;
}
}
}

View File

@@ -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 {

View File

@@ -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;
}
}
}

View File

@@ -27,6 +27,7 @@
&-prepend {
margin-right: 2px;
display: flex;
}
&-close {

View File

@@ -204,3 +204,8 @@
top: 4px;
}
}
.badge-group-settings {
align-self: center;
padding: 0 16px;
}

View File

@@ -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": "<strong>Add-ons:</strong> __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 <code>\\cite{}</code> 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__</0> <1>(includes plan and any add-ons)</1>",
"billed_monthly_at": "Billed monthly at <0>__price__</0> <1>(includes plan and any add-ons)</1>",
"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</0>.",
"get_most_subscription_discover_premium_features": "Get the most from your __appName__ subscription. <0>Discover premium features</0>.",
"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__</0>",
"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": "Youre on the <0>__currentPlan__</0> plan and youre upgrading to the <0>__nextPlan__</0> plan. If youre interested in a site-wide Overleaf Commons plan, please <1>get in touch</1>.",
"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</0>. You can add this manually or click <1>Import IdP metadata</1> 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</0> and <1>troubleshooting guide</1> for more help.",
@@ -1728,6 +1738,7 @@
"rename": "Rename",
"rename_project": "Rename Project",
"renaming": "Renaming",
"renews_on": "Renews on <0>__date__</0>",
"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</0>",
"supports_up_to_x_users_incl_y_additional_licenses": "Supports up to <0>__count__ users</0> (Incl. <1>__additionalLicenses__</1> 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__</0> plan.",
"sure_you_want_to_change_plan": "Are you sure you want to change plan to <0>__planName__</0>?",
"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. </0>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. </0>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__</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_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 cant add or change your password because your group or organization uses <0>single sign-on (SSO)</0>.",
"you_cant_join_this_group_subscription": "You cant join this group subscription",
"you_cant_reset_password_due_to_sso": "You cant reset your password because your group or organization uses SSO. <0>Log in with SSO</0>.",
"you_dont_have_any_add_ons_on_your_account": "You dont have any add-ons on your account.",
"you_dont_have_any_repositories": "You dont 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",

View File

@@ -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: {},
},
}

View File

@@ -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 () {

View File

@@ -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
)

View File

@@ -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<string, string>
currentPlanDisplayPrice?: string
pendingAdditionalLicenses?: number
pendingTotalLicenses?: number

View File

@@ -27,6 +27,7 @@ export type RecurlyAddOn = {
add_on_code: string
quantity: number
unit_amount_in_cents: number
displayPrice: string
}
export type PendingRecurlyPlan = {