mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-25 02:00:10 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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 || []),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
1
services/web/frontend/js/shared/svgs/sparkle.svg
Normal file
1
services/web/frontend/js/shared/svgs/sparkle.svg
Normal 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 |
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
&-prepend {
|
||||
margin-right: 2px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&-close {
|
||||
|
||||
@@ -204,3 +204,8 @@
|
||||
top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-group-settings {
|
||||
align-self: center;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
@@ -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": "You’re on the <0>__currentPlan__</0> plan and you’re upgrading to the <0>__nextPlan__</0> plan. If you’re 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 can’t add or change your password because your group or organization uses <0>single sign-on (SSO)</0>.",
|
||||
"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</0>.",
|
||||
"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",
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,6 +27,7 @@ export type RecurlyAddOn = {
|
||||
add_on_code: string
|
||||
quantity: number
|
||||
unit_amount_in_cents: number
|
||||
displayPrice: string
|
||||
}
|
||||
|
||||
export type PendingRecurlyPlan = {
|
||||
|
||||
Reference in New Issue
Block a user