first commit
This commit is contained in:
@@ -0,0 +1,454 @@
|
||||
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 { FetchError, 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, Nullable } from '../../../../../../types/utils'
|
||||
import { sendMB } from '../../../../infrastructure/event-tracking'
|
||||
|
||||
export const MAX_NUMBER_OF_USERS = 20
|
||||
|
||||
type CostSummaryData = MergeAndOverride<
|
||||
SubscriptionChangePreview,
|
||||
{ change: AddOnUpdate }
|
||||
>
|
||||
|
||||
function AddSeats() {
|
||||
const { t } = useTranslation()
|
||||
const groupName = getMeta('ol-groupName')
|
||||
const subscriptionId = getMeta('ol-subscriptionId')
|
||||
const totalLicenses = Number(getMeta('ol-totalLicenses'))
|
||||
const isProfessional = getMeta('ol-isProfessional')
|
||||
const [addSeatsInputError, setAddSeatsInputError] = useState<string>()
|
||||
const [shouldContactSales, setShouldContactSales] = useState(false)
|
||||
const controller = useAbortController()
|
||||
const { signal: addSeatsSignal } = useAbortController()
|
||||
const { signal: contactSalesSignal } = useAbortController()
|
||||
const {
|
||||
isLoading: isLoadingCostSummary,
|
||||
isError: isErrorCostSummary,
|
||||
runAsync: runAsyncCostSummary,
|
||||
data: costSummaryData,
|
||||
reset: resetCostSummaryData,
|
||||
error: errorCostSummary,
|
||||
} = useAsync<CostSummaryData, FetchError>()
|
||||
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 debouncedTrackUserEnterSeatNumberEvent = useMemo(
|
||||
() =>
|
||||
debounce((value: number) => {
|
||||
sendMB('flex-add-users-form', {
|
||||
action: 'enter-seat-number',
|
||||
seatNumber: value,
|
||||
})
|
||||
}, 500),
|
||||
[]
|
||||
)
|
||||
|
||||
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)
|
||||
debouncedTrackUserEnterSeatNumberEvent(seats)
|
||||
|
||||
if (seats > MAX_NUMBER_OF_USERS) {
|
||||
debouncedCostSummaryRequest.cancel()
|
||||
shouldContactSales = true
|
||||
} else {
|
||||
debouncedCostSummaryRequest(seats, controller.signal)
|
||||
}
|
||||
} else {
|
||||
debouncedTrackUserEnterSeatNumberEvent.cancel()
|
||||
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) {
|
||||
sendMB('flex-add-users-form', {
|
||||
action: 'click-send-request-button',
|
||||
})
|
||||
const post = postJSON(
|
||||
'/user/subscription/group/add-users/sales-contact-form',
|
||||
{
|
||||
signal: contactSalesSignal,
|
||||
body: {
|
||||
adding: rawSeats,
|
||||
},
|
||||
}
|
||||
)
|
||||
runAsyncSendMailToSales(post).catch(debugConsole.error)
|
||||
} else {
|
||||
sendMB('flex-add-users-form', {
|
||||
action: 'click-add-user-button',
|
||||
})
|
||||
const post = postJSON('/user/subscription/group/add-users/create', {
|
||||
signal: addSeatsSignal,
|
||||
body: { adding: Number(rawSeats) },
|
||||
})
|
||||
runAsyncAddSeats(post)
|
||||
.then(() => {
|
||||
sendMB('flex-add-users-success')
|
||||
})
|
||||
.catch(() => {
|
||||
debugConsole.error()
|
||||
sendMB('flex-add-users-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_licenses')}
|
||||
content={
|
||||
<Trans
|
||||
i18nKey="youve_added_x_more_licenses_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}
|
||||
data-testid="add-more-users-group-form"
|
||||
>
|
||||
<div className="d-grid gap-1">
|
||||
<h4 className="fw-bold m-0 card-description-secondary">
|
||||
{t('buy_more_licenses')}
|
||||
</h4>
|
||||
<div>
|
||||
{t('your_current_plan_supports_up_to_x_licenses', {
|
||||
users: totalLicenses,
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<Trans
|
||||
i18nKey="if_you_want_to_reduce_the_number_of_licenses_please_contact_support"
|
||||
components={[
|
||||
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
|
||||
<a
|
||||
href="/contact"
|
||||
onClick={() => {
|
||||
sendMB('flex-add-users-form', {
|
||||
action: 'click-contact-customer-support-link',
|
||||
})
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormGroup controlId="number-of-users-input">
|
||||
<FormLabel>
|
||||
{t('how_many_licenses_do_you_want_to_buy')}
|
||||
</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>
|
||||
<CostSummarySection
|
||||
isLoadingCostSummary={isLoadingCostSummary}
|
||||
isErrorCostSummary={isErrorCostSummary}
|
||||
errorCostSummary={errorCostSummary}
|
||||
shouldContactSales={shouldContactSales}
|
||||
costSummaryData={costSummaryData}
|
||||
totalLicenses={totalLicenses}
|
||||
/>
|
||||
<div className="d-flex align-items-center justify-content-end gap-2">
|
||||
{!isProfessional && (
|
||||
<a
|
||||
href="/user/subscription/group/upgrade-subscription"
|
||||
rel="noreferrer noopener"
|
||||
className="me-auto"
|
||||
onClick={() => {
|
||||
sendMB('flex-upgrade')
|
||||
}}
|
||||
>
|
||||
{t('upgrade_my_plan')}
|
||||
</a>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
href="/user/subscription"
|
||||
onClick={() =>
|
||||
sendMB('flex-add-users-form', {
|
||||
action: 'click-cancel-button',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={
|
||||
isAddingSeats ||
|
||||
isLoadingCostSummary ||
|
||||
isSendingMailToSales
|
||||
}
|
||||
isLoading={isAddingSeats || isSendingMailToSales}
|
||||
>
|
||||
{shouldContactSales ? t('send_request') : t('buy_licenses')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CostSummarySectionProps = {
|
||||
isLoadingCostSummary: boolean
|
||||
isErrorCostSummary: boolean
|
||||
errorCostSummary: Nullable<FetchError>
|
||||
shouldContactSales: boolean
|
||||
costSummaryData: Nullable<CostSummaryData>
|
||||
totalLicenses: number
|
||||
}
|
||||
|
||||
function CostSummarySection({
|
||||
isLoadingCostSummary,
|
||||
isErrorCostSummary,
|
||||
errorCostSummary,
|
||||
shouldContactSales,
|
||||
costSummaryData,
|
||||
totalLicenses,
|
||||
}: CostSummarySectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (isLoadingCostSummary) {
|
||||
return <LoadingSpinner className="ms-auto me-auto" />
|
||||
}
|
||||
|
||||
if (shouldContactSales) {
|
||||
return (
|
||||
<Notification
|
||||
content={
|
||||
<Trans
|
||||
i18nKey="if_you_want_more_than_x_licenses_on_your_plan_we_need_to_add_them_for_you"
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
components={[<b />]}
|
||||
values={{ count: MAX_NUMBER_OF_USERS }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
/>
|
||||
}
|
||||
type="info"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isErrorCostSummary) {
|
||||
if (errorCostSummary?.data?.code === 'subtotal_limit_exceeded') {
|
||||
return (
|
||||
<Notification
|
||||
type="error"
|
||||
content={
|
||||
<Trans
|
||||
i18nKey="sorry_there_was_an_issue_adding_x_users_to_your_subscription"
|
||||
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
|
||||
components={[<a href="/contact" rel="noreferrer noopener" />]}
|
||||
values={{ count: errorCostSummary?.data?.adding }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Notification type="error" content={t('generic_something_went_wrong')} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CostSummary
|
||||
subscriptionChange={costSummaryData}
|
||||
totalLicenses={totalLicenses}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default withErrorBoundary(AddSeats)
|
@@ -0,0 +1,160 @@
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Card, ListGroup } from 'react-bootstrap-5'
|
||||
import { formatCurrency } 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()
|
||||
const factor = 100
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="card-gray card-description-secondary"
|
||||
data-testid="cost-summary"
|
||||
>
|
||||
<Card.Body className="d-grid gap-2 p-3">
|
||||
<div>
|
||||
<div className="fw-bold">{t('cost_summary')}</div>
|
||||
{subscriptionChange ? (
|
||||
<Trans
|
||||
i18nKey="youre_adding_x_licenses_to_your_plan_giving_you_a_total_of_y_licenses"
|
||||
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_licenses_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"
|
||||
data-testid="plan"
|
||||
>
|
||||
<span className="me-auto">
|
||||
{subscriptionChange.nextInvoice.plan.name} x{' '}
|
||||
{subscriptionChange.change.addOn.quantity -
|
||||
subscriptionChange.change.addOn.prevQuantity}{' '}
|
||||
{t('licenses')}
|
||||
</span>
|
||||
<span data-testid="price">
|
||||
{formatCurrency(
|
||||
subscriptionChange.immediateCharge.subtotal,
|
||||
subscriptionChange.currency
|
||||
)}
|
||||
</span>
|
||||
</ListGroup.Item>
|
||||
{subscriptionChange.immediateCharge.discount !== 0 && (
|
||||
<ListGroup.Item className="bg-transparent border-0 px-0 gap-3 card-description-secondary">
|
||||
<span className="me-auto">{t('discount')}</span>
|
||||
<span data-testid="discount">
|
||||
(
|
||||
{formatCurrency(
|
||||
subscriptionChange.immediateCharge.discount,
|
||||
subscriptionChange.currency
|
||||
)}
|
||||
)
|
||||
</span>
|
||||
</ListGroup.Item>
|
||||
)}
|
||||
<ListGroup.Item
|
||||
className="bg-transparent border-0 px-0 gap-3 card-description-secondary"
|
||||
data-testid="tax"
|
||||
>
|
||||
<span className="me-auto">
|
||||
{t('vat')} ·{' '}
|
||||
{Math.round(
|
||||
subscriptionChange.nextInvoice.tax.rate * 100 * factor
|
||||
) / factor}
|
||||
%
|
||||
</span>
|
||||
<span data-testid="price">
|
||||
{formatCurrency(
|
||||
subscriptionChange.immediateCharge.tax,
|
||||
subscriptionChange.currency
|
||||
)}
|
||||
</span>
|
||||
</ListGroup.Item>
|
||||
<ListGroup.Item
|
||||
className="bg-transparent border-0 px-0 gap-3 card-description-secondary"
|
||||
data-testid="total"
|
||||
>
|
||||
<strong className="me-auto">{t('total_due_today')}</strong>
|
||||
<strong data-testid="price">
|
||||
{formatCurrency(
|
||||
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_licenses_based_on_remaining_months'
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
'after_that_well_bill_you_x_total_y_subtotal_z_tax_annually_on_date_unless_you_cancel',
|
||||
{
|
||||
totalAmount: formatCurrency(
|
||||
subscriptionChange.nextInvoice.total,
|
||||
subscriptionChange.currency
|
||||
),
|
||||
subtotalAmount: formatCurrency(
|
||||
subscriptionChange.nextInvoice.subtotal,
|
||||
subscriptionChange.currency
|
||||
),
|
||||
taxAmount: formatCurrency(
|
||||
subscriptionChange.nextInvoice.tax.amount,
|
||||
subscriptionChange.currency
|
||||
),
|
||||
date: formatTime(
|
||||
subscriptionChange.nextInvoice.date,
|
||||
'MMMM D'
|
||||
),
|
||||
}
|
||||
)}
|
||||
{subscriptionChange.immediateCharge.discount !== 0 &&
|
||||
` ${t('coupons_not_included')}.`}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default CostSummary
|
@@ -0,0 +1,14 @@
|
||||
import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n'
|
||||
import AddSeats from '@/features/group-management/components/add-seats/add-seats'
|
||||
|
||||
function Root() {
|
||||
const { isReady } = useWaitForI18n()
|
||||
|
||||
if (!isReady) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <AddSeats />
|
||||
}
|
||||
|
||||
export default Root
|
Reference in New Issue
Block a user