diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs index ef8d48291d..b3fced21fb 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs @@ -8,6 +8,8 @@ import SessionManager from '../Authentication/SessionManager.js' import UserAuditLogHandler from '../User/UserAuditLogHandler.js' 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 { Subscription } from "../../../../types/subscription/dashboard/subscription" @@ -111,7 +113,33 @@ async function _removeUserFromGroup( res.sendStatus(200) } +async function requestConfirmation(req, res) { + const { variant } = await SplitTestHandler.promises.getAssignment( + req, + res, + 'flexible-group-licensing' + ) + + if (variant !== 'enabled') { + 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') + } +} + export default { removeUserFromGroup: expressify(removeUserFromGroup), removeSelfFromGroup: expressify(removeSelfFromGroup), + requestConfirmation: expressify(requestConfirmation), } diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs index 4a4e4516fd..2305dc29de 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs @@ -80,6 +80,13 @@ export default { SubscriptionGroupController.removeSelfFromGroup ) + webRouter.get( + '/user/subscription/group/request-confirmation', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(subscriptionRateLimiter), + SubscriptionGroupController.requestConfirmation + ) + // Team invites webRouter.get( '/subscription/invites/:token/', diff --git a/services/web/app/views/subscriptions/request-confirmation-react.pug b/services/web/app/views/subscriptions/request-confirmation-react.pug new file mode 100644 index 0000000000..6687d9aa60 --- /dev/null +++ b/services/web/app/views/subscriptions/request-confirmation-react.pug @@ -0,0 +1,13 @@ +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 ce069bf772..e72e1ea9d2 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -603,6 +603,7 @@ "go_to_overleaf": "", "go_to_pdf_location_in_code": "", "go_to_settings": "", + "go_to_subscriptions": "", "group_admin": "", "group_invitations": "", "group_invite_has_been_sent_to_email": "", @@ -1019,6 +1020,7 @@ "other": "", "other_logs_and_files": "", "other_output_files": "", + "our_team_will_get_back_to_you_shortly": "", "our_values": "", "out_of_sync": "", "out_of_sync_detail": "", @@ -1793,6 +1795,7 @@ "we_are_unable_to_opt_you_into_this_experiment": "", "we_cant_find_any_sections_or_subsections_in_this_file": "", "we_do_not_share_personal_information": "", + "we_got_your_request": "", "we_logged_you_in": "", "we_sent_new_code": "", "webinars": "", diff --git a/services/web/frontend/js/features/group-management/components/request-confirmation.tsx b/services/web/frontend/js/features/group-management/components/request-confirmation.tsx new file mode 100644 index 0000000000..8323a27a4c --- /dev/null +++ b/services/web/frontend/js/features/group-management/components/request-confirmation.tsx @@ -0,0 +1,50 @@ +import { useTranslation } from 'react-i18next' +import { Card, CardBody, Row, Col } from 'react-bootstrap-5' +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' + +function RequestConfirmation() { + const { t } = useTranslation() + const groupName = getMeta('ol-groupName') + + return ( +
+ + +
+ +

{groupName || t('group_subscription')}

+
+ + +
+ +
+
+

{t('we_got_your_request')}

+
+ {t('our_team_will_get_back_to_you_shortly')} +
+
+
+ +
+
+
+ +
+
+ ) +} + +export default RequestConfirmation diff --git a/services/web/frontend/js/pages/user/subscription/group-management/request-confirmation.tsx b/services/web/frontend/js/pages/user/subscription/group-management/request-confirmation.tsx new file mode 100644 index 0000000000..5a5eba47bc --- /dev/null +++ b/services/web/frontend/js/pages/user/subscription/group-management/request-confirmation.tsx @@ -0,0 +1,8 @@ +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(, element) +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/card.scss b/services/web/frontend/stylesheets/bootstrap-5/components/card.scss index 250370ceda..fe6209f872 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/card.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/card.scss @@ -183,3 +183,21 @@ padding: var(--spacing-10) var(--spacing-09); } } + +.card-icon { + display: flex; + width: max-content; + margin: 0 auto; + padding: var(--spacing-08); + border-radius: 50%; + background-color: var(--bg-light-secondary); + color: var(--content-secondary); + + .material-symbols { + font-size: 2rem; + } +} + +.card-description-secondary { + color: var(--content-secondary); +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss index 573c914b55..076f04772a 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss @@ -158,3 +158,21 @@ } } } + +.group-heading { + display: flex; + align-items: center; + gap: var(--spacing-04); + margin-bottom: var(--spacing-06); + + h2 { + @include heading-lg; + + margin: 0; + word-break: break-all; + } + + .btn-ghost { + --bs-btn-bg: transparent; + } +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 13a0ed19c9..8cd9b4691c 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -860,6 +860,7 @@ "go_to_pdf_location_in_code": "Go to PDF location in code (Tip: double click on the PDF for best results)", "go_to_previous_page": "Go to previous page", "go_to_settings": "Go to settings", + "go_to_subscriptions": "Go to Subscriptions", "great_for_getting_started": "Great for getting started", "great_for_small_teams_and_departments": "Great for small teams and departments", "group": "Group", @@ -1455,6 +1456,7 @@ "other_output_files": "Download other output files", "other_sessions": "Other Sessions", "other_ways_to_log_in": "Other ways to log in", + "our_team_will_get_back_to_you_shortly": "Our team will get back to you shortly.", "our_values": "Our values", "out_of_sync": "Out of sync", "out_of_sync_detail": "Sorry, this file has gone out of sync and we need to do a full refresh.<0 /><1>Please see this help guide for more information", @@ -2438,6 +2440,7 @@ "we_cant_confirm_this_email": "We can’t confirm this email", "we_cant_find_any_sections_or_subsections_in_this_file": "We can’t find any sections or subsections in this file", "we_do_not_share_personal_information": "See our <0>Privacy Notice for details of how we treat your personal data", + "we_got_your_request": "We’ve got your request", "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 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.", diff --git a/services/web/test/frontend/features/group-management/components/request-confirmation.spec.tsx b/services/web/test/frontend/features/group-management/components/request-confirmation.spec.tsx new file mode 100644 index 0000000000..320372453b --- /dev/null +++ b/services/web/test/frontend/features/group-management/components/request-confirmation.spec.tsx @@ -0,0 +1,40 @@ +import '../../../helpers/bootstrap-5' +import RequestConfirmation from '@/features/group-management/components/request-confirmation' + +describe('request confirmation page', function () { + beforeEach(function () { + cy.window().then(win => { + win.metaAttributesCache.set('ol-groupName', 'My Awesome Team') + }) + cy.mount() + }) + + it('renders the back button', function () { + cy.findByTestId('group-heading').within(() => { + cy.findByRole('button', { name: /back to subscription/i }).should( + 'have.attr', + 'href', + '/user/subscription' + ) + }) + }) + + it('shows the group name', function () { + cy.findByTestId('group-heading').within(() => { + cy.findByRole('heading', { name: 'My Awesome Team' }) + }) + }) + + 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('renders the link to subscriptions', function () { + cy.findByRole('button', { name: /go to subscriptions/i }).should( + 'have.attr', + 'href', + '/user/subscription' + ) + }) +}) diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs index d113e0950e..78d13d3a61 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs @@ -1,5 +1,6 @@ import esmock from 'esmock' import sinon from 'sinon' +import { expect } from 'chai' const modulePath = '../../../../app/src/Features/Subscription/SubscriptionGroupController' @@ -24,6 +25,7 @@ describe('SubscriptionGroupController', function () { this.subscription = { _id: this.subscriptionId, + teamName: 'Cool group', } this.SubscriptionGroupHandler = { @@ -35,6 +37,7 @@ describe('SubscriptionGroupController', function () { this.SubscriptionLocator = { promises: { getSubscription: sinon.stub().resolves(this.subscription), + getUsersSubscription: sinon.stub().resolves(this.subscription), }, } @@ -61,6 +64,13 @@ describe('SubscriptionGroupController', function () { }, } + this.SplitTestHandler = { + promises: { + getAssignment: sinon.stub().resolves({ variant: 'default' }), + }, + getAssignment: sinon.stub().yields(null, { variant: 'default' }), + } + this.Controller = await esmock.strict(modulePath, { '../../../../app/src/Features/Subscription/SubscriptionGroupHandler': this.SubscriptionGroupHandler, @@ -71,6 +81,12 @@ describe('SubscriptionGroupController', function () { '../../../../app/src/Features/User/UserAuditLogHandler': this.UserAuditLogHandler, '../../../../app/src/infrastructure/Modules': this.Modules, + '../../../../app/src/Features/SplitTests/SplitTestHandler': + this.SplitTestHandler, + '../../../../app/src/Features/Errors/ErrorController': + (this.ErrorController = { + notFound: sinon.stub(), + }), }) }) @@ -253,4 +269,19 @@ 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 + }) + }) })