From ab4d8fe1683277c14a8a75d5f2d34c4b450b67c3 Mon Sep 17 00:00:00 2001 From: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:48:03 +0200 Subject: [PATCH] Merge pull request #21841 from overleaf/ii-flexible-group-licensing-add-seats [web] Add seats to a group plan GitOrigin-RevId: 53497d2cb7aa7d1e7dc8291e391b24f7a32eeece --- .../Features/Subscription/RecurlyEntities.js | 40 +- .../SubscriptionGroupController.mjs | 118 +++++- .../Subscription/SubscriptionGroupHandler.js | 95 +++++ .../Subscription/SubscriptionRouter.mjs | 44 ++- .../web/app/views/subscriptions/add-seats.pug | 17 + .../request-confirmation-react.pug | 13 - .../web/frontend/extracted-translations.json | 21 ++ .../components/add-seats/add-seats.tsx | 341 ++++++++++++++++++ .../components/add-seats/cost-summary.tsx | 121 +++++++ .../components/add-seats/root.tsx | 19 + .../components/group-members.tsx | 2 +- ...st-confirmation.tsx => request-status.tsx} | 30 +- .../group-management/add-seats.tsx | 8 + .../group-management/request-confirmation.tsx | 8 - services/web/frontend/js/utils/meta.ts | 1 + .../bootstrap-5/components/card.scss | 8 + .../bootstrap-5/components/list-group.scss | 2 + services/web/locales/en.json | 23 +- ...ation.spec.tsx => request-status.spec.tsx} | 15 +- .../src/Subscription/RecurlyEntitiesTest.js | 33 ++ .../SubscriptionGroupControllerTests.mjs | 26 +- .../SubscriptionGroupHandlerTests.js | 10 + services/web/types/currency-code.ts | 19 +- .../subscription-change-preview.ts | 15 +- 24 files changed, 936 insertions(+), 93 deletions(-) create mode 100644 services/web/app/views/subscriptions/add-seats.pug delete mode 100644 services/web/app/views/subscriptions/request-confirmation-react.pug create mode 100644 services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx create mode 100644 services/web/frontend/js/features/group-management/components/add-seats/cost-summary.tsx create mode 100644 services/web/frontend/js/features/group-management/components/add-seats/root.tsx rename services/web/frontend/js/features/group-management/components/{request-confirmation.tsx => request-status.tsx} (64%) create mode 100644 services/web/frontend/js/pages/user/subscription/group-management/add-seats.tsx delete mode 100644 services/web/frontend/js/pages/user/subscription/group-management/request-confirmation.tsx rename services/web/test/frontend/features/group-management/components/{request-confirmation.spec.tsx => request-status.spec.tsx} (70%) diff --git a/services/web/app/src/Features/Subscription/RecurlyEntities.js b/services/web/app/src/Features/Subscription/RecurlyEntities.js index c04baaee8a..dbbcdc3356 100644 --- a/services/web/app/src/Features/Subscription/RecurlyEntities.js +++ b/services/web/app/src/Features/Subscription/RecurlyEntities.js @@ -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, diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs index b3fced21fb..33792bb184 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs @@ -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} + */ +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} + */ +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} + */ +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 + ), } diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js index 40fe5cc5ba..42c79279ca 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js @@ -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, }, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs index 75d5f4dda8..e186aa710f 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs @@ -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 diff --git a/services/web/app/views/subscriptions/add-seats.pug b/services/web/app/views/subscriptions/add-seats.pug new file mode 100644 index 0000000000..0c43707df3 --- /dev/null +++ b/services/web/app/views/subscriptions/add-seats.pug @@ -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 diff --git a/services/web/app/views/subscriptions/request-confirmation-react.pug b/services/web/app/views/subscriptions/request-confirmation-react.pug deleted file mode 100644 index 6687d9aa60..0000000000 --- a/services/web/app/views/subscriptions/request-confirmation-react.pug +++ /dev/null @@ -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 diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index cae10c11bf..6d8da57d3b 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx b/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx new file mode 100644 index 0000000000..3eeff80d04 --- /dev/null +++ b/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx @@ -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() + 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 + >() + 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) => { + 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) => { + 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(null) + + useEffect(() => { + const handleUnload = () => formRef.current?.reset() + window.addEventListener('beforeunload', handleUnload) + + return () => window.removeEventListener('beforeunload', handleUnload) + }, []) + + if (isErrorAddingSeats || isErrorSendingMailToSales) { + return ( + ]} + /> + } + /> + ) + } + + if (isSuccessAddingSeats) { + return ( + , + ]} + values={{ users: addedSeatsData?.adding }} + shouldUnescape + tOptions={{ interpolation: { escapeValue: true } }} + /> + } + /> + ) + } + + if (isSuccessSendingMailToSales) { + return ( + + ) + } + + return ( +
+ + +
+ +

{groupName || t('group_subscription')}

+
+ + +
+
+

+ {t('add_more_users')} +

+
+ {t('your_current_plan_supports_up_to_x_users', { + users: totalLicenses, + })} +
+
+ ]} + /> +
+
+
+ + + {t('how_many_users_do_you_want_to_add')} + + + {Boolean(addSeatsInputError) && ( + {addSeatsInputError} + )} + +
+ {isLoadingCostSummary ? ( + + ) : shouldContactSales ? ( +
+ ]} + values={{ count: 50 }} + shouldUnescape + tOptions={{ interpolation: { escapeValue: true } }} + /> + } + type="info" + /> +
+ ) : ( + + )} +
+ + {t('upgrade_my_plan')} + + + +
+ +
+
+ +
+
+ ) +} + +export default withErrorBoundary(AddSeats) diff --git a/services/web/frontend/js/features/group-management/components/add-seats/cost-summary.tsx b/services/web/frontend/js/features/group-management/components/add-seats/cost-summary.tsx new file mode 100644 index 0000000000..dc19f165c8 --- /dev/null +++ b/services/web/frontend/js/features/group-management/components/add-seats/cost-summary.tsx @@ -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 ( + + +
+
{t('cost_summary')}
+ {subscriptionChange ? ( + , // eslint-disable-line react/jsx-key + , // 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' + ) + )} +
+ {subscriptionChange && ( + <> +
+ + + + {subscriptionChange.nextInvoice.plan.name} x{' '} + {subscriptionChange.change.addOn.quantity - + subscriptionChange.change.addOn.prevQuantity}{' '} + {t('seats')} + + + {formatCurrencyLocalized( + subscriptionChange.immediateCharge.subtotal, + subscriptionChange.currency + )} + + + + + {t('sales_tax')} ·{' '} + {subscriptionChange.nextInvoice.tax.rate * 100}% + + + {formatCurrencyLocalized( + subscriptionChange.immediateCharge.tax, + subscriptionChange.currency + )} + + + + {t('total_due_today')} + + {formatCurrencyLocalized( + subscriptionChange.immediateCharge.total, + subscriptionChange.currency + )} + + + +
+
+
+ {t( + 'we_will_charge_you_now_for_the_cost_of_your_additional_users_based_on_remaining_months' + )} +
+
+ {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' + ), + } + )} +
+ + )} +
+
+ ) +} + +export default CostSummary diff --git a/services/web/frontend/js/features/group-management/components/add-seats/root.tsx b/services/web/frontend/js/features/group-management/components/add-seats/root.tsx new file mode 100644 index 0000000000..af6d74928d --- /dev/null +++ b/services/web/frontend/js/features/group-management/components/add-seats/root.tsx @@ -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 ( + + + + ) +} + +export default Root diff --git a/services/web/frontend/js/features/group-management/components/group-members.tsx b/services/web/frontend/js/features/group-management/components/group-members.tsx index 9b87c0ecfb..9db6742792 100644 --- a/services/web/frontend/js/features/group-management/components/group-members.tsx +++ b/services/web/frontend/js/features/group-management/components/group-members.tsx @@ -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')}. ) diff --git a/services/web/frontend/js/features/group-management/components/request-confirmation.tsx b/services/web/frontend/js/features/group-management/components/request-status.tsx similarity index 64% rename from services/web/frontend/js/features/group-management/components/request-confirmation.tsx rename to services/web/frontend/js/features/group-management/components/request-status.tsx index 8323a27a4c..ca581949eb 100644 --- a/services/web/frontend/js/features/group-management/components/request-confirmation.tsx +++ b/services/web/frontend/js/features/group-management/components/request-status.tsx @@ -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 (
- +
-
- +
+
-

{t('we_got_your_request')}

-
- {t('our_team_will_get_back_to_you_shortly')} -
+

+ {title} +

+
{content}