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() 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() 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) => { 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) => { 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(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('buy_more_licenses')}

{t('your_current_plan_supports_up_to_x_licenses', { users: totalLicenses, })}
{ sendMB('flex-add-users-form', { action: 'click-contact-customer-support-link', }) }} />, ]} />
{t('how_many_licenses_do_you_want_to_buy')} {Boolean(addSeatsInputError) && ( {addSeatsInputError} )}
{!isProfessional && ( { sendMB('flex-upgrade') }} > {t('upgrade_my_plan')} )}
) } type CostSummarySectionProps = { isLoadingCostSummary: boolean isErrorCostSummary: boolean errorCostSummary: Nullable shouldContactSales: boolean costSummaryData: Nullable totalLicenses: number } function CostSummarySection({ isLoadingCostSummary, isErrorCostSummary, errorCostSummary, shouldContactSales, costSummaryData, totalLicenses, }: CostSummarySectionProps) { const { t } = useTranslation() if (isLoadingCostSummary) { return } if (shouldContactSales) { return ( ]} values={{ count: MAX_NUMBER_OF_USERS }} shouldUnescape tOptions={{ interpolation: { escapeValue: true } }} /> } type="info" /> ) } if (isErrorCostSummary) { if (errorCostSummary?.data?.code === 'subtotal_limit_exceeded') { return ( ]} values={{ count: errorCostSummary?.data?.adding }} shouldUnescape tOptions={{ interpolation: { escapeValue: true } }} /> } /> ) } return ( ) } return ( ) } export default withErrorBoundary(AddSeats)