mirror of
https://github.com/overleaf/overleaf.git
synced 2025-04-20 21:06:08 +00:00
Merge pull request #21841 from overleaf/ii-flexible-group-licensing-add-seats
[web] Add seats to a group plan GitOrigin-RevId: 53497d2cb7aa7d1e7dc8291e391b24f7a32eeece
This commit is contained in:
parent
f856ddce87
commit
ab4d8fe168
24 changed files with 936 additions and 93 deletions
|
@ -151,6 +151,44 @@ class RecurlySubscription {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an add-on on this subscription
|
||||
*
|
||||
* @param {string} code
|
||||
* @param {number} quantity
|
||||
* @return {RecurlySubscriptionChangeRequest} - the change request to send to
|
||||
* Recurly
|
||||
*
|
||||
* @throws {AddOnNotPresentError} if the subscription doesn't have the add-on
|
||||
*/
|
||||
getRequestForAddOnUpdate(code, quantity) {
|
||||
if (!this.hasAddOn(code)) {
|
||||
throw new AddOnNotPresentError(
|
||||
'Subscription does not have add-on to update',
|
||||
{
|
||||
subscriptionId: this.id,
|
||||
addOnCode: code,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const addOnUpdates = this.addOns.map(addOn => {
|
||||
const update = addOn.toAddOnUpdate()
|
||||
|
||||
if (update.code === code) {
|
||||
update.quantity = quantity
|
||||
}
|
||||
|
||||
return update
|
||||
})
|
||||
|
||||
return new RecurlySubscriptionChangeRequest({
|
||||
subscription: this,
|
||||
timeframe: 'now',
|
||||
addOnUpdates,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an add-on from this subscription
|
||||
*
|
||||
|
@ -162,7 +200,7 @@ class RecurlySubscription {
|
|||
getRequestForAddOnRemoval(code) {
|
||||
if (!this.hasAddOn(code)) {
|
||||
throw new AddOnNotPresentError(
|
||||
'Subscripiton does not have add-on to remove',
|
||||
'Subscription does not have add-on to remove',
|
||||
{
|
||||
subscriptionId: this.id,
|
||||
addOnCode: code,
|
||||
|
|
|
@ -10,6 +10,8 @@ import { expressify } from '@overleaf/promise-utils'
|
|||
import Modules from '../../infrastructure/Modules.js'
|
||||
import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
|
||||
import ErrorController from '../Errors/ErrorController.js'
|
||||
import SalesContactFormController from '../../../../modules/cms/app/src/controllers/SalesContactFormController.mjs'
|
||||
import UserGetter from '../User/UserGetter.js'
|
||||
|
||||
/**
|
||||
* @import { Subscription } from "../../../../types/subscription/dashboard/subscription"
|
||||
|
@ -113,7 +115,98 @@ async function _removeUserFromGroup(
|
|||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async function requestConfirmation(req, res) {
|
||||
/**
|
||||
* @param {import("express").Request} req
|
||||
* @param {import("express").Response} res
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function addSeatsToGroupSubscription(req, res) {
|
||||
try {
|
||||
const { subscription, plan } =
|
||||
await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails(
|
||||
req
|
||||
)
|
||||
await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
|
||||
|
||||
res.render('subscriptions/add-seats', {
|
||||
subscriptionId: subscription._id,
|
||||
groupName: subscription.teamName,
|
||||
totalLicenses: subscription.membersLimit,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.err(
|
||||
{ error },
|
||||
'error while getting users group subscription details'
|
||||
)
|
||||
return res.redirect('/user/subscription')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("express").Request} req
|
||||
* @param {import("express").Response} res
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function previewAddSeatsSubscriptionChange(req, res) {
|
||||
try {
|
||||
const preview =
|
||||
await SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange(
|
||||
req
|
||||
)
|
||||
|
||||
res.json(preview)
|
||||
} catch (error) {
|
||||
logger.err(
|
||||
{ error },
|
||||
'error trying to preview "add seats" subscription change'
|
||||
)
|
||||
return res.status(400).end()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("express").Request} req
|
||||
* @param {import("express").Response} res
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function createAddSeatsSubscriptionChange(req, res) {
|
||||
try {
|
||||
const preview =
|
||||
await SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange(
|
||||
req
|
||||
)
|
||||
|
||||
res.json(preview)
|
||||
} catch (error) {
|
||||
logger.err(
|
||||
{ error },
|
||||
'error trying to create "add seats" subscription change'
|
||||
)
|
||||
return res.status(400).end()
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const userEmail = await UserGetter.promises.getUserEmail(userId)
|
||||
const { adding } = req.body
|
||||
|
||||
req.body = {
|
||||
email: userEmail,
|
||||
subject: 'Self-Serve Group User Increase Request',
|
||||
estimatedNumberOfUsers: adding,
|
||||
message:
|
||||
`This email has been generated on behalf of user with email **${userEmail}** ` +
|
||||
'to request an increase in the total number of users ' +
|
||||
`for their subscription.\n\n` +
|
||||
`The requested number of users to add is: ${adding}`,
|
||||
inbox: 'sales',
|
||||
}
|
||||
|
||||
return SalesContactFormController.submitForm(req, res)
|
||||
}
|
||||
|
||||
async function flexibleLicensingSplitTest(req, res, next) {
|
||||
const { variant } = await SplitTestHandler.promises.getAssignment(
|
||||
req,
|
||||
res,
|
||||
|
@ -124,22 +217,19 @@ async function requestConfirmation(req, res) {
|
|||
return ErrorController.notFound(req, res)
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const subscription =
|
||||
await SubscriptionLocator.promises.getUsersSubscription(userId)
|
||||
|
||||
res.render('subscriptions/request-confirmation-react', {
|
||||
groupName: subscription.teamName,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.err({ error }, 'error trying to request seats to subscription')
|
||||
return res.render('/user/subscription')
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
export default {
|
||||
removeUserFromGroup: expressify(removeUserFromGroup),
|
||||
removeSelfFromGroup: expressify(removeSelfFromGroup),
|
||||
requestConfirmation: expressify(requestConfirmation),
|
||||
addSeatsToGroupSubscription: expressify(addSeatsToGroupSubscription),
|
||||
submitForm: expressify(submitForm),
|
||||
flexibleLicensingSplitTest: expressify(flexibleLicensingSplitTest),
|
||||
previewAddSeatsSubscriptionChange: expressify(
|
||||
previewAddSeatsSubscriptionChange
|
||||
),
|
||||
createAddSeatsSubscriptionChange: expressify(
|
||||
createAddSeatsSubscriptionChange
|
||||
),
|
||||
}
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
const { callbackify } = require('util')
|
||||
const SubscriptionUpdater = require('./SubscriptionUpdater')
|
||||
const SubscriptionLocator = require('./SubscriptionLocator')
|
||||
const SubscriptionController = require('./SubscriptionController')
|
||||
const { Subscription } = require('../../models/Subscription')
|
||||
const SessionManager = require('../Authentication/SessionManager')
|
||||
const RecurlyClient = require('./RecurlyClient')
|
||||
const PlansLocator = require('./PlansLocator')
|
||||
|
||||
async function removeUserFromGroup(subscriptionId, userIdToRemove) {
|
||||
await SubscriptionUpdater.promises.removeUserFromGroup(
|
||||
|
@ -50,15 +54,106 @@ async function _replaceInArray(model, property, oldValue, newValue) {
|
|||
await model.updateMany(query, { $pull: setOldValue })
|
||||
}
|
||||
|
||||
async function ensureFlexibleLicensingEnabled(plan) {
|
||||
if (!plan?.canUseFlexibleLicensing) {
|
||||
throw new Error('The group plan does not support flexible licencing')
|
||||
}
|
||||
}
|
||||
|
||||
async function getUsersGroupSubscriptionDetails(req) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const subscription =
|
||||
await SubscriptionLocator.promises.getUsersSubscription(userId)
|
||||
|
||||
if (!subscription.groupPlan) {
|
||||
throw new Error('User subscription is not a group plan')
|
||||
}
|
||||
|
||||
const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
|
||||
|
||||
const recurlySubscription = await RecurlyClient.promises.getSubscription(
|
||||
subscription.recurlySubscription_id
|
||||
)
|
||||
|
||||
return {
|
||||
subscription,
|
||||
recurlySubscription,
|
||||
plan,
|
||||
}
|
||||
}
|
||||
|
||||
async function _addSeatsSubscriptionChange(req) {
|
||||
const adding = req.body.adding
|
||||
const { recurlySubscription, plan } =
|
||||
await getUsersGroupSubscriptionDetails(req)
|
||||
await ensureFlexibleLicensingEnabled(plan)
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const currentAddonQuantity =
|
||||
recurlySubscription.addOns.find(
|
||||
addOn => addOn.code === plan.membersLimitAddOn
|
||||
)?.quantity ?? 0
|
||||
// Keeps only the new total quantity of addon
|
||||
const nextAddonQuantity = currentAddonQuantity + adding
|
||||
const changeRequest = recurlySubscription.getRequestForAddOnUpdate(
|
||||
plan.membersLimitAddOn,
|
||||
nextAddonQuantity
|
||||
)
|
||||
|
||||
return {
|
||||
changeRequest,
|
||||
userId,
|
||||
currentAddonQuantity,
|
||||
recurlySubscription,
|
||||
plan,
|
||||
}
|
||||
}
|
||||
|
||||
async function previewAddSeatsSubscriptionChange(req) {
|
||||
const { changeRequest, userId, currentAddonQuantity, plan } =
|
||||
await _addSeatsSubscriptionChange(req)
|
||||
const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId)
|
||||
const subscriptionChange =
|
||||
await RecurlyClient.promises.previewSubscriptionChange(changeRequest)
|
||||
const subscriptionChangePreview =
|
||||
await SubscriptionController.makeChangePreview(
|
||||
{
|
||||
type: 'add-on-update',
|
||||
addOn: {
|
||||
code: plan.membersLimitAddOn,
|
||||
quantity: subscriptionChange.nextAddOns.find(
|
||||
addon => addon.code === plan.membersLimitAddOn
|
||||
).quantity,
|
||||
prevQuantity: currentAddonQuantity,
|
||||
},
|
||||
},
|
||||
subscriptionChange,
|
||||
paymentMethod
|
||||
)
|
||||
|
||||
return subscriptionChangePreview
|
||||
}
|
||||
|
||||
async function createAddSeatsSubscriptionChange(req) {
|
||||
const { changeRequest } = await _addSeatsSubscriptionChange(req)
|
||||
await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
|
||||
|
||||
return { adding: req.body.adding }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
removeUserFromGroup: callbackify(removeUserFromGroup),
|
||||
replaceUserReferencesInGroups: callbackify(replaceUserReferencesInGroups),
|
||||
ensureFlexibleLicensingEnabled: callbackify(ensureFlexibleLicensingEnabled),
|
||||
getTotalConfirmedUsersInGroup: callbackify(getTotalConfirmedUsersInGroup),
|
||||
isUserPartOfGroup: callbackify(isUserPartOfGroup),
|
||||
promises: {
|
||||
removeUserFromGroup,
|
||||
replaceUserReferencesInGroups,
|
||||
ensureFlexibleLicensingEnabled,
|
||||
getTotalConfirmedUsersInGroup,
|
||||
isUserPartOfGroup,
|
||||
getUsersGroupSubscriptionDetails,
|
||||
previewAddSeatsSubscriptionChange,
|
||||
createAddSeatsSubscriptionChange,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { RateLimiter } from '../../infrastructure/RateLimiter.js'
|
|||
import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
|
||||
import Settings from '@overleaf/settings'
|
||||
import { Joi, validate } from '../../infrastructure/Validation.js'
|
||||
import SupportRouter from '../../../../modules/support/app/src/SupportRouter.mjs'
|
||||
|
||||
const teamInviteRateLimiter = new RateLimiter('team-invite', {
|
||||
points: 10,
|
||||
|
@ -18,6 +19,14 @@ const subscriptionRateLimiter = new RateLimiter('subscription', {
|
|||
duration: 60,
|
||||
})
|
||||
|
||||
const MAX_NUMBER_OF_USERS = 50
|
||||
|
||||
const addSeatsValidateSchema = {
|
||||
body: Joi.object({
|
||||
adding: Joi.number().integer().min(1).max(MAX_NUMBER_OF_USERS).required(),
|
||||
}),
|
||||
}
|
||||
|
||||
export default {
|
||||
apply(webRouter, privateApiRouter, publicApiRouter) {
|
||||
if (!Settings.enableSubscriptions) {
|
||||
|
@ -62,10 +71,41 @@ export default {
|
|||
)
|
||||
|
||||
webRouter.get(
|
||||
'/user/subscription/group/request-confirmation',
|
||||
'/user/subscription/group/add-users',
|
||||
AuthenticationController.requireLogin(),
|
||||
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
|
||||
SubscriptionGroupController.requestConfirmation
|
||||
SubscriptionGroupController.flexibleLicensingSplitTest,
|
||||
SubscriptionGroupController.addSeatsToGroupSubscription
|
||||
)
|
||||
|
||||
webRouter.post(
|
||||
'/user/subscription/group/add-users/preview',
|
||||
AuthenticationController.requireLogin(),
|
||||
validate(addSeatsValidateSchema),
|
||||
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
|
||||
SubscriptionGroupController.previewAddSeatsSubscriptionChange
|
||||
)
|
||||
|
||||
webRouter.post(
|
||||
'/user/subscription/group/add-users/create',
|
||||
AuthenticationController.requireLogin(),
|
||||
validate(addSeatsValidateSchema),
|
||||
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
|
||||
SubscriptionGroupController.createAddSeatsSubscriptionChange
|
||||
)
|
||||
|
||||
webRouter.post(
|
||||
'/user/subscription/group/add-users/sales-contact-form',
|
||||
validate({
|
||||
body: Joi.object({
|
||||
adding: Joi.number().integer().min(MAX_NUMBER_OF_USERS).required(),
|
||||
}),
|
||||
}),
|
||||
RateLimiterMiddleware.rateLimit(
|
||||
SupportRouter.rateLimiters.supportRequests
|
||||
),
|
||||
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
|
||||
SubscriptionGroupController.submitForm
|
||||
)
|
||||
|
||||
// Team invites
|
||||
|
|
17
services/web/app/views/subscriptions/add-seats.pug
Normal file
17
services/web/app/views/subscriptions/add-seats.pug
Normal file
|
@ -0,0 +1,17 @@
|
|||
extends ../layout-marketing
|
||||
|
||||
block vars
|
||||
- bootstrap5PageStatus = 'enabled' // Enforce BS5 version
|
||||
|
||||
block entrypointVar
|
||||
- entrypoint = 'pages/user/subscription/group-management/add-seats'
|
||||
|
||||
block append meta
|
||||
meta(name="ol-subscriptionData" data-type="json" content=subscriptionData)
|
||||
meta(name="ol-groupName", data-type="string", content=groupName)
|
||||
meta(name="ol-subscriptionId", data-type="string", content=subscriptionId)
|
||||
meta(name="ol-totalLicenses", data-type="number", content=totalLicenses)
|
||||
|
||||
block content
|
||||
main.content.content-alt#main-content
|
||||
#add-seats-root
|
|
@ -1,13 +0,0 @@
|
|||
extends ../layout-marketing
|
||||
|
||||
block vars
|
||||
- bootstrap5PageStatus = 'enabled' // Enforce BS5 version
|
||||
|
||||
block entrypointVar
|
||||
- entrypoint = 'pages/user/subscription/group-management/request-confirmation'
|
||||
|
||||
block append meta
|
||||
meta(name="ol-groupName", data-type="string", content=groupName)
|
||||
|
||||
block content
|
||||
main.content.content-alt#subscription-manage-group-root
|
|
@ -82,6 +82,7 @@
|
|||
"add_role_and_department": "",
|
||||
"add_to_dictionary": "",
|
||||
"add_to_tag": "",
|
||||
"add_users": "",
|
||||
"add_your_comment_here": "",
|
||||
"add_your_first_group_member_now": "",
|
||||
"added_by_on": "",
|
||||
|
@ -93,6 +94,7 @@
|
|||
"address_second_line_optional": "",
|
||||
"adjust_column_width": "",
|
||||
"advanced_reference_search_mode": "",
|
||||
"after_that_well_bill_you_x_annually_on_date_unless_you_cancel": "",
|
||||
"aggregate_changed": "",
|
||||
"aggregate_to": "",
|
||||
"agree_with_the_terms": "",
|
||||
|
@ -291,6 +293,7 @@
|
|||
"copy_project": "",
|
||||
"copy_response": "",
|
||||
"copying": "",
|
||||
"cost_summary": "",
|
||||
"country": "",
|
||||
"country_flag": "",
|
||||
"coupon_code": "",
|
||||
|
@ -456,6 +459,7 @@
|
|||
"enter_any_size_including_units_or_valid_latex_command": "",
|
||||
"enter_image_url": "",
|
||||
"enter_the_confirmation_code": "",
|
||||
"enter_the_number_of_users_youd_like_to_add_to_see_the_cost_breakdown": "",
|
||||
"equation_preview": "",
|
||||
"error": "",
|
||||
"error_opening_document": "",
|
||||
|
@ -682,6 +686,7 @@
|
|||
"hotkey_undo": "",
|
||||
"hotkeys": "",
|
||||
"how_it_works": "",
|
||||
"how_many_users_do_you_want_to_add": "",
|
||||
"how_to_create_tables": "",
|
||||
"how_to_insert_images": "",
|
||||
"how_we_use_your_data": "",
|
||||
|
@ -689,6 +694,8 @@
|
|||
"i_want_to_stay": "",
|
||||
"id": "",
|
||||
"if_you_need_to_customize_your_table_further_you_can": "",
|
||||
"if_you_want_more_than_x_users_on_your_plan_we_need_to_add_them_for_you": "",
|
||||
"if_you_want_to_reduce_the_number_of_users_please_contact_support": "",
|
||||
"if_your_occupation_not_listed_type_full_name": "",
|
||||
"ignore_validation_errors": "",
|
||||
"ill_take_it": "",
|
||||
|
@ -760,6 +767,7 @@
|
|||
"ip_address": "",
|
||||
"is_email_affiliated": "",
|
||||
"issued_on": "",
|
||||
"it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "",
|
||||
"join_beta_program": "",
|
||||
"join_now": "",
|
||||
"join_overleaf_labs": "",
|
||||
|
@ -1281,6 +1289,7 @@
|
|||
"revoke_invite": "",
|
||||
"right": "",
|
||||
"role": "",
|
||||
"sales_tax": "",
|
||||
"saml_auth_error": "",
|
||||
"saml_identity_exists_error": "",
|
||||
"saml_invalid_signature_error": "",
|
||||
|
@ -1324,6 +1333,7 @@
|
|||
"search_whole_word": "",
|
||||
"search_within_selection": "",
|
||||
"searched_path_for_lines_containing": "",
|
||||
"seats": "",
|
||||
"security": "",
|
||||
"see_changes_in_your_documents_live": "",
|
||||
"select_a_column_or_a_merged_cell_to_align": "",
|
||||
|
@ -1361,6 +1371,7 @@
|
|||
"selection_deleted": "",
|
||||
"send_first_message": "",
|
||||
"send_message": "",
|
||||
"send_request": "",
|
||||
"sending": "",
|
||||
"sent": "",
|
||||
"server_error": "",
|
||||
|
@ -1658,6 +1669,7 @@
|
|||
"tooltip_hide_pdf": "",
|
||||
"tooltip_show_filetree": "",
|
||||
"tooltip_show_pdf": "",
|
||||
"total_due_today": "",
|
||||
"total_per_month": "",
|
||||
"total_per_year": "",
|
||||
"total_with_subtotal_and_tax": "",
|
||||
|
@ -1746,6 +1758,7 @@
|
|||
"upgrade": "",
|
||||
"upgrade_cc_btn": "",
|
||||
"upgrade_for_12x_more_compile_time": "",
|
||||
"upgrade_my_plan": "",
|
||||
"upgrade_now": "",
|
||||
"upgrade_to_add_more_editors": "",
|
||||
"upgrade_to_add_more_editors_and_access_collaboration_features": "",
|
||||
|
@ -1777,6 +1790,9 @@
|
|||
"valid": "",
|
||||
"valid_sso_configuration": "",
|
||||
"validation_issue_entry_description": "",
|
||||
"value_must_be_a_number": "",
|
||||
"value_must_be_a_whole_number": "",
|
||||
"value_must_be_at_least_x": "",
|
||||
"vat": "",
|
||||
"vat_number": "",
|
||||
"verify_email_address_before_enabling_managed_users": "",
|
||||
|
@ -1813,6 +1829,7 @@
|
|||
"we_got_your_request": "",
|
||||
"we_logged_you_in": "",
|
||||
"we_sent_new_code": "",
|
||||
"we_will_charge_you_now_for_the_cost_of_your_additional_users_based_on_remaining_months": "",
|
||||
"webinars": "",
|
||||
"website_status": "",
|
||||
"wed_love_you_to_stay": "",
|
||||
|
@ -1894,6 +1911,7 @@
|
|||
"your_affiliation_is_confirmed": "",
|
||||
"your_browser_does_not_support_this_feature": "",
|
||||
"your_compile_timed_out": "",
|
||||
"your_current_plan_supports_up_to_x_users": "",
|
||||
"your_current_project_will_revert_to_the_version_from_time": "",
|
||||
"your_git_access_info": "",
|
||||
"your_git_access_info_bullet_1": "",
|
||||
|
@ -1922,10 +1940,13 @@
|
|||
"youre_about_to_disable_single_sign_on": "",
|
||||
"youre_about_to_enable_single_sign_on": "",
|
||||
"youre_about_to_enable_single_sign_on_sso_only": "",
|
||||
"youre_adding_x_users_to_your_plan_giving_you_a_total_of_y_users": "",
|
||||
"youre_already_setup_for_sso": "",
|
||||
"youre_joining": "",
|
||||
"youre_on_free_trial_which_ends_on": "",
|
||||
"youre_signed_in_as_logout": "",
|
||||
"youve_added_more_users": "",
|
||||
"youve_added_x_more_users_to_your_subscription_invite_people": "",
|
||||
"youve_lost_edit_access": "",
|
||||
"youve_unlinked_all_users": "",
|
||||
"zoom_in": "",
|
||||
|
|
|
@ -0,0 +1,341 @@
|
|||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { debounce } from 'lodash'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import withErrorBoundary from '@/infrastructure/error-boundary'
|
||||
import useAbortController from '@/shared/hooks/use-abort-controller'
|
||||
import LoadingSpinner from '@/shared/components/loading-spinner'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
|
||||
import {
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
FormGroup,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
} from 'react-bootstrap-5'
|
||||
import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
|
||||
import Button from '@/features/ui/components/bootstrap-5/button'
|
||||
import CostSummary from '@/features/group-management/components/add-seats/cost-summary'
|
||||
import RequestStatus from '@/features/group-management/components/request-status'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import * as yup from 'yup'
|
||||
import {
|
||||
AddOnUpdate,
|
||||
SubscriptionChangePreview,
|
||||
} from '../../../../../../types/subscription/subscription-change-preview'
|
||||
import { MergeAndOverride } from '../../../../../../types/utils'
|
||||
|
||||
const MAX_NUMBER_OF_USERS = 50
|
||||
|
||||
function AddSeats() {
|
||||
const { t } = useTranslation()
|
||||
const groupName = getMeta('ol-groupName')
|
||||
const subscriptionId = getMeta('ol-subscriptionId')
|
||||
const totalLicenses = Number(getMeta('ol-totalLicenses'))
|
||||
const [addSeatsInputError, setAddSeatsInputError] = useState<string>()
|
||||
const [shouldContactSales, setShouldContactSales] = useState(false)
|
||||
const controller = useAbortController()
|
||||
const { signal: addSeatsSignal } = useAbortController()
|
||||
const { signal: contactSalesSignal } = useAbortController()
|
||||
const {
|
||||
isLoading: isLoadingCostSummary,
|
||||
runAsync: runAsyncCostSummary,
|
||||
data: costSummaryData,
|
||||
reset: resetCostSummaryData,
|
||||
} = useAsync<
|
||||
MergeAndOverride<SubscriptionChangePreview, { change: AddOnUpdate }>
|
||||
>()
|
||||
const {
|
||||
isLoading: isAddingSeats,
|
||||
isError: isErrorAddingSeats,
|
||||
isSuccess: isSuccessAddingSeats,
|
||||
runAsync: runAsyncAddSeats,
|
||||
data: addedSeatsData,
|
||||
} = useAsync<{ adding: number }>()
|
||||
const {
|
||||
isLoading: isSendingMailToSales,
|
||||
isError: isErrorSendingMailToSales,
|
||||
isSuccess: isSuccessSendingMailToSales,
|
||||
runAsync: runAsyncSendMailToSales,
|
||||
} = useAsync()
|
||||
|
||||
const addSeatsValidationSchema = useMemo(() => {
|
||||
return yup
|
||||
.number()
|
||||
.typeError(t('value_must_be_a_number'))
|
||||
.integer(t('value_must_be_a_whole_number'))
|
||||
.min(1, t('value_must_be_at_least_x', { value: 1 }))
|
||||
.required(t('this_field_is_required'))
|
||||
}, [t])
|
||||
|
||||
const debouncedCostSummaryRequest = useMemo(
|
||||
() =>
|
||||
debounce((value: number, signal: AbortSignal) => {
|
||||
const post = postJSON('/user/subscription/group/add-users/preview', {
|
||||
signal,
|
||||
body: { adding: value },
|
||||
})
|
||||
runAsyncCostSummary(post).catch(debugConsole.error)
|
||||
}, 500),
|
||||
[runAsyncCostSummary]
|
||||
)
|
||||
|
||||
const validateSeats = async (value: string | undefined) => {
|
||||
try {
|
||||
await addSeatsValidationSchema.validate(value)
|
||||
setAddSeatsInputError(undefined)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error instanceof yup.ValidationError) {
|
||||
setAddSeatsInputError(error.errors[0])
|
||||
} else {
|
||||
debugConsole.error(error)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSeatsChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value === '' ? undefined : e.target.value
|
||||
const isValidSeatsNumber = await validateSeats(value)
|
||||
let shouldContactSales = false
|
||||
|
||||
if (isValidSeatsNumber) {
|
||||
const seats = Number(value)
|
||||
|
||||
if (seats > MAX_NUMBER_OF_USERS) {
|
||||
debouncedCostSummaryRequest.cancel()
|
||||
shouldContactSales = true
|
||||
} else {
|
||||
debouncedCostSummaryRequest(seats, controller.signal)
|
||||
}
|
||||
} else {
|
||||
debouncedCostSummaryRequest.cancel()
|
||||
}
|
||||
|
||||
resetCostSummaryData()
|
||||
setShouldContactSales(shouldContactSales)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const rawSeats =
|
||||
formData.get('seats') === ''
|
||||
? undefined
|
||||
: (formData.get('seats') as string)
|
||||
|
||||
if (!(await validateSeats(rawSeats))) {
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldContactSales) {
|
||||
const post = postJSON(
|
||||
'/user/subscription/group/add-users/sales-contact-form',
|
||||
{
|
||||
signal: contactSalesSignal,
|
||||
body: {
|
||||
adding: rawSeats,
|
||||
},
|
||||
}
|
||||
)
|
||||
runAsyncSendMailToSales(post).catch(debugConsole.error)
|
||||
} else {
|
||||
const post = postJSON('/user/subscription/group/add-users/create', {
|
||||
signal: addSeatsSignal,
|
||||
body: { adding: Number(rawSeats) },
|
||||
})
|
||||
runAsyncAddSeats(post).catch(debugConsole.error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedCostSummaryRequest.cancel()
|
||||
}
|
||||
}, [debouncedCostSummaryRequest])
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleUnload = () => formRef.current?.reset()
|
||||
window.addEventListener('beforeunload', handleUnload)
|
||||
|
||||
return () => window.removeEventListener('beforeunload', handleUnload)
|
||||
}, [])
|
||||
|
||||
if (isErrorAddingSeats || isErrorSendingMailToSales) {
|
||||
return (
|
||||
<RequestStatus
|
||||
variant="danger"
|
||||
icon="error"
|
||||
title={t('something_went_wrong')}
|
||||
content={
|
||||
<Trans
|
||||
i18nKey="it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch"
|
||||
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
|
||||
components={[<a href="/contact" rel="noreferrer noopener" />]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isSuccessAddingSeats) {
|
||||
return (
|
||||
<RequestStatus
|
||||
variant="primary"
|
||||
icon="check_circle"
|
||||
title={t('youve_added_more_users')}
|
||||
content={
|
||||
<Trans
|
||||
i18nKey="youve_added_x_more_users_to_your_subscription_invite_people"
|
||||
components={[
|
||||
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
|
||||
<a
|
||||
href={`/manage/groups/${subscriptionId}/members`}
|
||||
rel="noreferrer noopener"
|
||||
/>,
|
||||
]}
|
||||
values={{ users: addedSeatsData?.adding }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isSuccessSendingMailToSales) {
|
||||
return (
|
||||
<RequestStatus
|
||||
icon="email"
|
||||
title={t('we_got_your_request')}
|
||||
content={t('our_team_will_get_back_to_you_shortly')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<Row>
|
||||
<Col xxl={5} xl={6} lg={7} md={9} className="mx-auto">
|
||||
<div className="group-heading" data-testid="group-heading">
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
href="/user/subscription"
|
||||
size="lg"
|
||||
icon="arrow_back"
|
||||
accessibilityLabel={t('back_to_subscription')}
|
||||
/>
|
||||
<h2>{groupName || t('group_subscription')}</h2>
|
||||
</div>
|
||||
<Card className="card-description-secondary">
|
||||
<Card.Body>
|
||||
<form
|
||||
noValidate
|
||||
className="d-grid gap-4"
|
||||
onSubmit={handleSubmit}
|
||||
ref={formRef}
|
||||
>
|
||||
<div className="d-grid gap-1">
|
||||
<h4 className="fw-bold m-0 card-description-secondary">
|
||||
{t('add_more_users')}
|
||||
</h4>
|
||||
<div>
|
||||
{t('your_current_plan_supports_up_to_x_users', {
|
||||
users: totalLicenses,
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<Trans
|
||||
i18nKey="if_you_want_to_reduce_the_number_of_users_please_contact_support"
|
||||
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
|
||||
components={[<a href="/contact" />]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormGroup controlId="number-of-users-input">
|
||||
<FormLabel>
|
||||
{t('how_many_users_do_you_want_to_add')}
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
type="text"
|
||||
required
|
||||
className="w-25"
|
||||
name="seats"
|
||||
disabled={isLoadingCostSummary}
|
||||
onChange={handleSeatsChange}
|
||||
isInvalid={Boolean(addSeatsInputError)}
|
||||
/>
|
||||
{Boolean(addSeatsInputError) && (
|
||||
<FormText type="error">{addSeatsInputError}</FormText>
|
||||
)}
|
||||
</FormGroup>
|
||||
</div>
|
||||
{isLoadingCostSummary ? (
|
||||
<LoadingSpinner className="ms-auto me-auto" />
|
||||
) : shouldContactSales ? (
|
||||
<div>
|
||||
<Notification
|
||||
content={
|
||||
<Trans
|
||||
i18nKey="if_you_want_more_than_x_users_on_your_plan_we_need_to_add_them_for_you"
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
components={[<b />]}
|
||||
values={{ count: 50 }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
/>
|
||||
}
|
||||
type="info"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<CostSummary
|
||||
subscriptionChange={costSummaryData}
|
||||
totalLicenses={totalLicenses}
|
||||
/>
|
||||
)}
|
||||
<div className="d-flex align-items-center justify-content-end gap-2">
|
||||
<a
|
||||
href="user/subscription/group/upgrade-subscription"
|
||||
rel="noreferrer noopener"
|
||||
className="me-auto"
|
||||
>
|
||||
{t('upgrade_my_plan')}
|
||||
</a>
|
||||
<Button variant="secondary" href="/user/subscription">
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={
|
||||
isAddingSeats ||
|
||||
isLoadingCostSummary ||
|
||||
isSendingMailToSales
|
||||
}
|
||||
isLoading={isAddingSeats || isSendingMailToSales}
|
||||
>
|
||||
{shouldContactSales ? t('send_request') : t('add_users')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withErrorBoundary(AddSeats)
|
|
@ -0,0 +1,121 @@
|
|||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Card, ListGroup } from 'react-bootstrap-5'
|
||||
import { formatCurrencyLocalized } from '@/shared/utils/currency'
|
||||
import { formatTime } from '@/features/utils/format-date'
|
||||
import {
|
||||
AddOnUpdate,
|
||||
SubscriptionChangePreview,
|
||||
} from '../../../../../../types/subscription/subscription-change-preview'
|
||||
import { MergeAndOverride } from '../../../../../../types/utils'
|
||||
|
||||
type CostSummaryProps = {
|
||||
subscriptionChange: MergeAndOverride<
|
||||
SubscriptionChangePreview,
|
||||
{ change: AddOnUpdate }
|
||||
> | null
|
||||
totalLicenses: number
|
||||
}
|
||||
|
||||
function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Card className="card-gray card-description-secondary">
|
||||
<Card.Body className="d-grid gap-2 p-3">
|
||||
<div>
|
||||
<div className="fw-bold">{t('cost_summary')}</div>
|
||||
{subscriptionChange ? (
|
||||
<Trans
|
||||
i18nKey="youre_adding_x_users_to_your_plan_giving_you_a_total_of_y_users"
|
||||
components={[
|
||||
<b />, // eslint-disable-line react/jsx-key
|
||||
<b />, // eslint-disable-line react/jsx-key
|
||||
]}
|
||||
values={{
|
||||
adding:
|
||||
subscriptionChange.change.addOn.quantity -
|
||||
subscriptionChange.change.addOn.prevQuantity,
|
||||
total:
|
||||
totalLicenses +
|
||||
subscriptionChange.change.addOn.quantity -
|
||||
subscriptionChange.change.addOn.prevQuantity,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
/>
|
||||
) : (
|
||||
t(
|
||||
'enter_the_number_of_users_youd_like_to_add_to_see_the_cost_breakdown'
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{subscriptionChange && (
|
||||
<>
|
||||
<div>
|
||||
<ListGroup>
|
||||
<ListGroup.Item className="bg-transparent border-0 px-0 gap-3 card-description-secondary">
|
||||
<span className="me-auto">
|
||||
{subscriptionChange.nextInvoice.plan.name} x{' '}
|
||||
{subscriptionChange.change.addOn.quantity -
|
||||
subscriptionChange.change.addOn.prevQuantity}{' '}
|
||||
{t('seats')}
|
||||
</span>
|
||||
<span>
|
||||
{formatCurrencyLocalized(
|
||||
subscriptionChange.immediateCharge.subtotal,
|
||||
subscriptionChange.currency
|
||||
)}
|
||||
</span>
|
||||
</ListGroup.Item>
|
||||
<ListGroup.Item className="bg-transparent border-0 px-0 gap-3 card-description-secondary">
|
||||
<span className="me-auto">
|
||||
{t('sales_tax')} ·{' '}
|
||||
{subscriptionChange.nextInvoice.tax.rate * 100}%
|
||||
</span>
|
||||
<span>
|
||||
{formatCurrencyLocalized(
|
||||
subscriptionChange.immediateCharge.tax,
|
||||
subscriptionChange.currency
|
||||
)}
|
||||
</span>
|
||||
</ListGroup.Item>
|
||||
<ListGroup.Item className="bg-transparent border-0 px-0 gap-3 card-description-secondary">
|
||||
<strong className="me-auto">{t('total_due_today')}</strong>
|
||||
<strong>
|
||||
{formatCurrencyLocalized(
|
||||
subscriptionChange.immediateCharge.total,
|
||||
subscriptionChange.currency
|
||||
)}
|
||||
</strong>
|
||||
</ListGroup.Item>
|
||||
</ListGroup>
|
||||
<hr className="m-0" />
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
'we_will_charge_you_now_for_the_cost_of_your_additional_users_based_on_remaining_months'
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
'after_that_well_bill_you_x_annually_on_date_unless_you_cancel',
|
||||
{
|
||||
subtotal: formatCurrencyLocalized(
|
||||
subscriptionChange.nextInvoice.total,
|
||||
subscriptionChange.currency
|
||||
),
|
||||
date: formatTime(
|
||||
subscriptionChange.nextInvoice.date,
|
||||
'MMMM D'
|
||||
),
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default CostSummary
|
|
@ -0,0 +1,19 @@
|
|||
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()
|
||||
|
||||
if (!isReady) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SplitTestProvider>
|
||||
<AddSeats />
|
||||
</SplitTestProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default Root
|
|
@ -66,7 +66,7 @@ export default function GroupMembers() {
|
|||
href="/user/subscription/group/add-users"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{t('add_more_users')}
|
||||
{t('add_more_users')}.
|
||||
</a>
|
||||
</small>
|
||||
)
|
||||
|
|
|
@ -4,15 +4,23 @@ import Button from '@/features/ui/components/bootstrap-5/button'
|
|||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import getMeta from '@/utils/meta'
|
||||
import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
|
||||
import classnames from 'classnames'
|
||||
|
||||
function RequestConfirmation() {
|
||||
type RequestStatusProps = {
|
||||
icon: string
|
||||
title: string
|
||||
content: React.ReactNode
|
||||
variant?: 'primary' | 'danger'
|
||||
}
|
||||
|
||||
function RequestStatus({ icon, title, content, variant }: RequestStatusProps) {
|
||||
const { t } = useTranslation()
|
||||
const groupName = getMeta('ol-groupName')
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<Row>
|
||||
<Col xl={{ span: 4, offset: 4 }} md={{ span: 6, offset: 3 }}>
|
||||
<Col xxl={5} xl={6} lg={7} md={9} className="mx-auto">
|
||||
<div className="group-heading" data-testid="group-heading">
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
|
@ -25,14 +33,18 @@ function RequestConfirmation() {
|
|||
</div>
|
||||
<Card>
|
||||
<CardBody className="d-grid gap-3">
|
||||
<div className="card-icon">
|
||||
<MaterialIcon type="email" />
|
||||
<div
|
||||
className={classnames('card-icon', {
|
||||
[`text-${variant}`]: variant,
|
||||
})}
|
||||
>
|
||||
<MaterialIcon type={icon} />
|
||||
</div>
|
||||
<div className="d-grid gap-2 text-center">
|
||||
<h3 className="mb-0 fw-bold">{t('we_got_your_request')}</h3>
|
||||
<div className="card-description-secondary">
|
||||
{t('our_team_will_get_back_to_you_shortly')}
|
||||
</div>
|
||||
<h3 className="mb-0 fw-bold" data-testid="title">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="card-description-secondary">{content}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Button variant="secondary" href="/user/subscription">
|
||||
|
@ -47,4 +59,4 @@ function RequestConfirmation() {
|
|||
)
|
||||
}
|
||||
|
||||
export default RequestConfirmation
|
||||
export default RequestStatus
|
|
@ -0,0 +1,8 @@
|
|||
import '../base'
|
||||
import ReactDOM from 'react-dom'
|
||||
import Root from '@/features/group-management/components/add-seats/root'
|
||||
|
||||
const element = document.getElementById('add-seats-root')
|
||||
if (element) {
|
||||
ReactDOM.render(<Root />, element)
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
import '../base'
|
||||
import ReactDOM from 'react-dom'
|
||||
import RequestConfirmation from '@/features/group-management/components/request-confirmation'
|
||||
|
||||
const element = document.getElementById('subscription-manage-group-root')
|
||||
if (element) {
|
||||
ReactDOM.render(<RequestConfirmation />, element)
|
||||
}
|
|
@ -201,6 +201,7 @@ export interface Meta {
|
|||
'ol-tags': Tag[]
|
||||
'ol-teamInvites': TeamInvite[]
|
||||
'ol-thirdPartyIds': ThirdPartyIds
|
||||
'ol-totalLicenses': number
|
||||
'ol-translationIoNotLoaded': string
|
||||
'ol-translationLoadErrorMessage': string
|
||||
'ol-translationMaintenance': string
|
||||
|
|
|
@ -196,6 +196,14 @@
|
|||
.material-symbols {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
&.text-primary {
|
||||
background-color: var(--bg-accent-03);
|
||||
}
|
||||
|
||||
&.text-danger {
|
||||
background-color: var(--bg-danger-03);
|
||||
}
|
||||
}
|
||||
|
||||
.card-description-secondary {
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
.list-group-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
|
|
@ -86,7 +86,7 @@
|
|||
"add_more_editors": "Add more editors",
|
||||
"add_more_managers": "Add more managers",
|
||||
"add_more_members": "Add more members",
|
||||
"add_more_users": "Add more users.",
|
||||
"add_more_users": "Add more users",
|
||||
"add_new_email": "Add new email",
|
||||
"add_ons_are": "<strong>Add-ons:</strong> __addOnName__",
|
||||
"add_or_remove_project_from_tag": "Add or remove project from tag __tagName__",
|
||||
|
@ -94,6 +94,7 @@
|
|||
"add_role_and_department": "Add role and department",
|
||||
"add_to_dictionary": "Add to Dictionary",
|
||||
"add_to_tag": "Add to tag",
|
||||
"add_users": "Add users",
|
||||
"add_your_comment_here": "Add your comment here",
|
||||
"add_your_first_group_member_now": "Add your first group members now",
|
||||
"added": "added",
|
||||
|
@ -112,6 +113,7 @@
|
|||
"administration_and_security": "Administration and security",
|
||||
"advanced_reference_search": "Advanced <0>reference search</0>",
|
||||
"advanced_reference_search_mode": "Advanced reference search",
|
||||
"after_that_well_bill_you_x_annually_on_date_unless_you_cancel": "After that, we’ll bill you __subtotal__ + applicable taxes annually on __date__, unless you cancel.",
|
||||
"aggregate_changed": "Changed",
|
||||
"aggregate_to": "to",
|
||||
"agree_with_the_terms": "I agree with the Overleaf terms",
|
||||
|
@ -393,6 +395,7 @@
|
|||
"copy_project": "Copy Project",
|
||||
"copy_response": "Copy response",
|
||||
"copying": "Copying",
|
||||
"cost_summary": "Cost summary",
|
||||
"could_not_connect_to_collaboration_server": "Could not connect to collaboration server",
|
||||
"could_not_connect_to_websocket_server": "Could not connect to WebSocket server",
|
||||
"could_not_load_translations": "Could not load translations",
|
||||
|
@ -616,6 +619,7 @@
|
|||
"enter_any_size_including_units_or_valid_latex_command": "Enter any size (including units) or valid LaTeX command",
|
||||
"enter_image_url": "Enter image URL",
|
||||
"enter_the_confirmation_code": "Enter the 6-digit confirmation code sent to __email__.",
|
||||
"enter_the_number_of_users_youd_like_to_add_to_see_the_cost_breakdown": "Enter the number of users you’d like to add to see the cost breakdown.",
|
||||
"enter_your_email_address": "Enter your email address",
|
||||
"enter_your_email_address_below_and_we_will_send_you_a_link_to_reset_your_password": "Enter your email address below, and we will send you a link to reset your password",
|
||||
"enter_your_new_password": "Enter your new password",
|
||||
|
@ -956,6 +960,7 @@
|
|||
"hotkeys": "Hotkeys",
|
||||
"how_it_works": "How it works",
|
||||
"how_many_users_do_you_need": "How many users do you need?",
|
||||
"how_many_users_do_you_want_to_add": "How many users do you want to add?",
|
||||
"how_to_create_tables": "How to create tables",
|
||||
"how_to_insert_images": "How to insert images",
|
||||
"how_we_use_your_data": "How we use your data",
|
||||
|
@ -966,6 +971,8 @@
|
|||
"if_have_existing_can_link": "If you have an existing <b>__appName__</b> account on another email, you can link it to your <b>__institutionName__</b> account by clicking <b>__clickText__</b>.",
|
||||
"if_owner_can_link": "If you own the <b>__appName__</b> account with <b>__email__</b>, you will be allowed to link it to your <b>__institutionName__</b> institutional account.",
|
||||
"if_you_need_to_customize_your_table_further_you_can": "If you need to customize your table further, you can. Using LaTeX code, you can change anything from table styles and border styles to colors and column widths. <0>Read our guide</0> to using tables in LaTeX to help you get started.",
|
||||
"if_you_want_more_than_x_users_on_your_plan_we_need_to_add_them_for_you": "If you want more than __count__ users on your plan, we need to add them for you. Just click <0>Send request</0> below and we’ll be happy to help.",
|
||||
"if_you_want_to_reduce_the_number_of_users_please_contact_support": "If you want to reduce the number of users on your plan, please <0>contact customer support</0>.",
|
||||
"if_your_occupation_not_listed_type_full_name": "If your __occupation__ isn’t listed, you can type the full name.",
|
||||
"ignore_and_continue_institution_linking": "You can also ignore this and <a href=\"__link__\">continue to __appName__ with your <b>__email__</b> account</a>.",
|
||||
"ignore_validation_errors": "Don’t check syntax",
|
||||
|
@ -1070,6 +1077,7 @@
|
|||
"is_not_used_on_any_other_website": "is not used on any other website",
|
||||
"issued_on": "Issued: __date__",
|
||||
"it": "Italian",
|
||||
"it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "It looks like that didn’t work. You can try again or <0>get in touch</0> with our Support team for more help.",
|
||||
"ja": "Japanese",
|
||||
"january": "January",
|
||||
"join_beta_program": "Join beta program",
|
||||
|
@ -1808,6 +1816,7 @@
|
|||
"ro": "Romanian",
|
||||
"role": "Role",
|
||||
"ru": "Russian",
|
||||
"sales_tax": "Sales tax",
|
||||
"saml": "SAML",
|
||||
"saml_auth_error": "Sorry, your identity provider responded with an error. Please contact your administrator for more information.",
|
||||
"saml_authentication_required_error": "Other login methods have been disabled by your group administrator. Please use your group SSO login.",
|
||||
|
@ -1862,6 +1871,7 @@
|
|||
"search_whole_word": "Whole word",
|
||||
"search_within_selection": "Within selection",
|
||||
"searched_path_for_lines_containing": "Searched __path__ for lines containing \"__query__\"",
|
||||
"seats": "Seats",
|
||||
"secondary_email_password_reset": "That email is registered as a secondary email. Please enter the primary email for your account.",
|
||||
"security": "Security",
|
||||
"see_changes_in_your_documents_live": "See changes in your documents, live",
|
||||
|
@ -1904,6 +1914,7 @@
|
|||
"send": "Send",
|
||||
"send_first_message": "Send your first message to your collaborators",
|
||||
"send_message": "Send message",
|
||||
"send_request": "Send request",
|
||||
"send_test_email": "Send a test email",
|
||||
"sending": "Sending",
|
||||
"sent": "Sent",
|
||||
|
@ -2274,6 +2285,7 @@
|
|||
"tooltip_show_pdf": "Click to show the PDF",
|
||||
"top_pick": "Top pick",
|
||||
"total": "Total",
|
||||
"total_due_today": "Total due today",
|
||||
"total_per_month": "Total per month",
|
||||
"total_per_year": "Total per year",
|
||||
"total_per_year_for_x_users": "total per year for __licenseSize__ users",
|
||||
|
@ -2378,6 +2390,7 @@
|
|||
"upgrade": "Upgrade",
|
||||
"upgrade_cc_btn": "Upgrade now, pay after 7 days",
|
||||
"upgrade_for_12x_more_compile_time": "Upgrade to get 12x more compile time",
|
||||
"upgrade_my_plan": "Upgrade my plan",
|
||||
"upgrade_now": "Upgrade now",
|
||||
"upgrade_to_add_more_editors": "Upgrade to add more editors to your project",
|
||||
"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.",
|
||||
|
@ -2420,6 +2433,9 @@
|
|||
"valid": "Valid",
|
||||
"valid_sso_configuration": "Valid SSO configuration",
|
||||
"validation_issue_entry_description": "A validation issue which prevented this project from compiling",
|
||||
"value_must_be_a_number": "Value must be a number",
|
||||
"value_must_be_a_whole_number": "Value must be a whole number",
|
||||
"value_must_be_at_least_x": "Value must be at least __value__",
|
||||
"vat": "VAT",
|
||||
"vat_number": "VAT Number",
|
||||
"verify_email_address_before_enabling_managed_users": "You need to verify your email address before enabling managed users.",
|
||||
|
@ -2460,6 +2476,7 @@
|
|||
"we_logged_you_in": "We have logged you in.",
|
||||
"we_may_also_contact_you_from_time_to_time_by_email_with_a_survey": "<0>We may also contact you</0> from time to time by email with a survey, or to see if you would like to participate in other user research initiatives",
|
||||
"we_sent_new_code": "We’ve sent a new code. If it doesn’t arrive, make sure to check your spam and any promotions folders.",
|
||||
"we_will_charge_you_now_for_the_cost_of_your_additional_users_based_on_remaining_months": "We’ll charge you now for the cost of your additional users based on the remaining months of your current subscription.",
|
||||
"webinars": "Webinars",
|
||||
"website_status": "Website status",
|
||||
"wed_love_you_to_stay": "We’d love you to stay",
|
||||
|
@ -2563,6 +2580,7 @@
|
|||
"your_affiliation_is_confirmed": "Your <0>__institutionName__</0> affiliation is confirmed.",
|
||||
"your_browser_does_not_support_this_feature": "Sorry, your browser doesn’t support this feature. Please update your browser to its latest version.",
|
||||
"your_compile_timed_out": "Your compile timed out",
|
||||
"your_current_plan_supports_up_to_x_users": "Your current plan supports up to __users__ users.",
|
||||
"your_current_project_will_revert_to_the_version_from_time": "Your current project will revert to the version from __timestamp__",
|
||||
"your_git_access_info": "Your Git authentication tokens should be entered whenever you’re prompted for a password.",
|
||||
"your_git_access_info_bullet_1": "You can have up to 10 tokens.",
|
||||
|
@ -2594,11 +2612,14 @@
|
|||
"youre_about_to_disable_single_sign_on": "You’re about to disable single sign-on for all group members.",
|
||||
"youre_about_to_enable_single_sign_on": "You’re about to enable single sign-on (SSO). Before you do this, you should ensure you’re confident the SSO configuration is correct and all your group members have managed user accounts.",
|
||||
"youre_about_to_enable_single_sign_on_sso_only": "You’re about to enable single sign-on (SSO). Before you do this, you should ensure you’re confident the SSO configuration is correct.",
|
||||
"youre_adding_x_users_to_your_plan_giving_you_a_total_of_y_users": "You’re adding <0>__adding__</0> users to your plan giving you a total of <1>__total__</1> users.",
|
||||
"youre_already_setup_for_sso": "You’re already set up for SSO",
|
||||
"youre_joining": "You’re joining",
|
||||
"youre_on_free_trial_which_ends_on": "You’re on a free trial which ends on <0>__date__</0>.",
|
||||
"youre_signed_in_as_logout": "You’re signed in as <0>__email__</0>. <1>Log out.</1>",
|
||||
"youre_signed_up": "You’re signed up",
|
||||
"youve_added_more_users": "You’ve added more users!",
|
||||
"youve_added_x_more_users_to_your_subscription_invite_people": "You’ve added __users__ more users to your subscription. <0>Invite people</0>.",
|
||||
"youve_lost_edit_access": "You’ve lost edit access",
|
||||
"youve_unlinked_all_users": "You’ve unlinked all users",
|
||||
"zh-CN": "Chinese",
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import '../../../helpers/bootstrap-5'
|
||||
import RequestConfirmation from '@/features/group-management/components/request-confirmation'
|
||||
import RequestStatus from '@/features/group-management/components/request-status'
|
||||
|
||||
describe('request confirmation page', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
})
|
||||
cy.mount(<RequestConfirmation />)
|
||||
cy.mount(
|
||||
<RequestStatus icon="email" title="Test title" content="Test content" />
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the back button', function () {
|
||||
|
@ -25,9 +27,12 @@ describe('request confirmation page', function () {
|
|||
})
|
||||
})
|
||||
|
||||
it('indicates the message was received', function () {
|
||||
cy.findByRole('heading', { name: /we’ve got your request/i })
|
||||
cy.findByText(/our team will get back to you shortly/i)
|
||||
it('shows the title', function () {
|
||||
cy.findByTestId('title').should('contain.text', 'Test title')
|
||||
})
|
||||
|
||||
it('shows the content', function () {
|
||||
cy.findByText('Test content')
|
||||
})
|
||||
|
||||
it('renders the link to subscriptions', function () {
|
|
@ -192,6 +192,39 @@ describe('RecurlyEntities', function () {
|
|||
})
|
||||
})
|
||||
|
||||
describe('getRequestForAddOnUpdate()', function () {
|
||||
it('returns a change request', function () {
|
||||
const {
|
||||
RecurlySubscriptionChangeRequest,
|
||||
RecurlySubscriptionAddOnUpdate,
|
||||
} = this.RecurlyEntities
|
||||
const newQuantity = 2
|
||||
const changeRequest = this.subscription.getRequestForAddOnUpdate(
|
||||
'add-on-code',
|
||||
newQuantity
|
||||
)
|
||||
expect(changeRequest).to.deep.equal(
|
||||
new RecurlySubscriptionChangeRequest({
|
||||
subscription: this.subscription,
|
||||
timeframe: 'now',
|
||||
addOnUpdates: [
|
||||
new RecurlySubscriptionAddOnUpdate({
|
||||
code: this.addOn.code,
|
||||
quantity: newQuantity,
|
||||
unitPrice: this.addOn.unitPrice,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("throws a AddOnNotPresentError if the subscription doesn't have the add-on", function () {
|
||||
expect(() =>
|
||||
this.subscription.getRequestForAddOnUpdate('another-add-on', 2)
|
||||
).to.throw(Errors.AddOnNotPresentError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRequestForAddOnRemoval()', function () {
|
||||
it('returns a change request', function () {
|
||||
const changeRequest = this.subscription.getRequestForAddOnRemoval(
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import esmock from 'esmock'
|
||||
import sinon from 'sinon'
|
||||
import { expect } from 'chai'
|
||||
|
||||
const modulePath =
|
||||
'../../../../app/src/Features/Subscription/SubscriptionGroupController'
|
||||
|
||||
|
@ -26,6 +26,7 @@ describe('SubscriptionGroupController', function () {
|
|||
this.subscription = {
|
||||
_id: this.subscriptionId,
|
||||
teamName: 'Cool group',
|
||||
groupPlan: true,
|
||||
}
|
||||
|
||||
this.SubscriptionGroupHandler = {
|
||||
|
@ -37,7 +38,6 @@ describe('SubscriptionGroupController', function () {
|
|||
this.SubscriptionLocator = {
|
||||
promises: {
|
||||
getSubscription: sinon.stub().resolves(this.subscription),
|
||||
getUsersSubscription: sinon.stub().resolves(this.subscription),
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -71,6 +71,12 @@ describe('SubscriptionGroupController', function () {
|
|||
getAssignment: sinon.stub().yields(null, { variant: 'default' }),
|
||||
}
|
||||
|
||||
this.UserGetter = {
|
||||
promises: {
|
||||
getUserEmail: sinon.stub().resolves(this.user),
|
||||
},
|
||||
}
|
||||
|
||||
this.Controller = await esmock.strict(modulePath, {
|
||||
'../../../../app/src/Features/Subscription/SubscriptionGroupHandler':
|
||||
this.SubscriptionGroupHandler,
|
||||
|
@ -83,6 +89,7 @@ describe('SubscriptionGroupController', function () {
|
|||
'../../../../app/src/infrastructure/Modules': this.Modules,
|
||||
'../../../../app/src/Features/SplitTests/SplitTestHandler':
|
||||
this.SplitTestHandler,
|
||||
'../../../../app/src/Features/User/UserGetter': this.UserGetter,
|
||||
'../../../../app/src/Features/Errors/ErrorController':
|
||||
(this.ErrorController = {
|
||||
notFound: sinon.stub(),
|
||||
|
@ -269,19 +276,4 @@ describe('SubscriptionGroupController', function () {
|
|||
this.Controller.removeSelfFromGroup(this.req, res, done)
|
||||
})
|
||||
})
|
||||
|
||||
describe('add seats', function () {
|
||||
it('render the request confirmation view', async function () {
|
||||
this.SplitTestHandler.promises.getAssignment.resolves({
|
||||
variant: 'enabled',
|
||||
})
|
||||
await this.Controller.requestConfirmation(this.req, {
|
||||
render: (viewPath, viewParams) => {
|
||||
expect(viewPath).to.equal('subscriptions/request-confirmation-react')
|
||||
expect(viewParams.groupName).to.equal('Cool group')
|
||||
},
|
||||
})
|
||||
expect(this.ErrorController.notFound).to.not.have.been.called
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -27,6 +27,10 @@ describe('SubscriptionGroupHandler', function () {
|
|||
},
|
||||
}
|
||||
|
||||
this.SubscriptionController = {
|
||||
makeChangePreview: sinon.stub().resolves(),
|
||||
}
|
||||
|
||||
this.SubscriptionUpdater = {
|
||||
promises: {
|
||||
removeUserFromGroup: sinon.stub().resolves(),
|
||||
|
@ -40,13 +44,19 @@ describe('SubscriptionGroupHandler', function () {
|
|||
findOne: sinon.stub().returns({ exec: sinon.stub().resolves }),
|
||||
}
|
||||
|
||||
this.RecurlyClient = {
|
||||
promises: {},
|
||||
}
|
||||
|
||||
this.Handler = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'./SubscriptionUpdater': this.SubscriptionUpdater,
|
||||
'./SubscriptionLocator': this.SubscriptionLocator,
|
||||
'./SubscriptionController': this.SubscriptionController,
|
||||
'../../models/Subscription': {
|
||||
Subscription: this.Subscription,
|
||||
},
|
||||
'./RecurlyClient': this.RecurlyClient,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,18 +1 @@
|
|||
export type CurrencyCode =
|
||||
| 'AUD'
|
||||
| 'BRL'
|
||||
| 'CAD'
|
||||
| 'CHF'
|
||||
| 'CLP'
|
||||
| 'COP'
|
||||
| 'DKK'
|
||||
| 'EUR'
|
||||
| 'GBP'
|
||||
| 'INR'
|
||||
| 'MXN'
|
||||
| 'NOK'
|
||||
| 'NZD'
|
||||
| 'PEN'
|
||||
| 'SEK'
|
||||
| 'SGD'
|
||||
| 'USD'
|
||||
export type { CurrencyCode } from './subscription/currency'
|
||||
|
|
|
@ -34,13 +34,20 @@ type AddOn = {
|
|||
amount: number
|
||||
}
|
||||
|
||||
export type SubscriptionChangeDescription = AddOnPurchase | PremiumSubscription
|
||||
export type SubscriptionChangeDescription =
|
||||
| AddOnPurchase
|
||||
| AddOnUpdate
|
||||
| PremiumSubscription
|
||||
|
||||
export type AddOnPurchase = {
|
||||
type: 'add-on-purchase'
|
||||
addOn: {
|
||||
code: string
|
||||
name: string
|
||||
addOn: Pick<AddOn, 'code' | 'name'>
|
||||
}
|
||||
|
||||
export type AddOnUpdate = {
|
||||
type: 'add-on-update'
|
||||
addOn: Pick<AddOn, 'code' | 'quantity'> & {
|
||||
prevQuantity: AddOn['quantity']
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue