[web] Migrate /user/subscription to BS5 (#20513)

* [web] Initialize BS5 in subscription page

* [web] Update subscription-dashboard.tsx for BS5

* [web] Update row-link.tsx for BS5

* [web] Update modals

* [web] Add `btn` to `btn-inline-link` classes

* [web] Update circle change-to-group circle price element

* [web] Replace `list-item-with-margin-bottom` with `mb-3`

* [web] Update form elements to BS5

* [web] Use `useContactUsModal`

* [web] Adjust tables margin/padding, and more

* [web] Update change-to-group-modal.tsx

* [web] Add gap to subscription buttons

* [web] Remove subscription page colspan for md and above

* [web] Use Notification component

* [web] Update "leave group" buttons

* [web] Fix tests: add `ol-user` meta tag

* [web] Nest .hover-highlight in #subscription-dashboard-root

* [web] Update to OLRow/OLCol

* [web] Update to OLButtons

* [web] Update to OLFormGroup

* [web] Naming: use BSversion prefix

* [web] Set CancelSubscriptionButton as ghost directly in component

* [web] Set "Plan" font size

* [web] Simplify cancel-subscription buttons

* [web] Remove `--neutral-10` ModalFooter background

* [web] Simplify circle styles

* [web] Center discount badge

* [web] Update fieldset label

* [web] Add `<ul>` around RowLink

* [web] Define SCSS for row-link component

* [web] Remove some use of utility classes

* [web] Revert and update `fieldset` changes (fixes tests)

* [web] Fixup some more OLButtons

* [web] Fixup use of OLRow/OLCol

* [web] Reduce spacing below "legend-as-label"

* [web] Use h5 instead of small in OLModalTitle

* [web] Revert OLCol removal on lg screens

I had removed them by mistake because I wasn't using the proper breakpoints

* [web] Add backdrop to nested modal ContactUsModal

* [web] Don't prefill project URL in ContactUsModal

* [web] Fix lint

* [web] Share `className` prop in BS5 and BS3 modals

* [web] Set sub-title font sans serif (BS3)

* [web] Update remaining Alerts to OLNotification

GitOrigin-RevId: 7fd975ae3e992cebfaf71d4e182f8e13ec886d09
This commit is contained in:
Antoine Clausse 2024-09-30 11:49:18 +02:00 committed by Copybot
parent d4bf47932e
commit 9997c4874f
36 changed files with 763 additions and 492 deletions

View file

@ -23,6 +23,7 @@ block append meta
meta(name="ol-fromPlansPage" data-type="boolean" content=fromPlansPage) meta(name="ol-fromPlansPage" data-type="boolean" content=fromPlansPage)
meta(name="ol-plans", data-type="json" content=plans) meta(name="ol-plans", data-type="json" content=plans)
meta(name="ol-groupSettingsEnabledFor", data-type="json" content=groupSettingsEnabledFor) meta(name="ol-groupSettingsEnabledFor", data-type="json" content=groupSettingsEnabledFor)
meta(name="ol-user" data-type="json" content=user)
if (personalSubscription && personalSubscription.recurly) if (personalSubscription && personalSubscription.recurly)
meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey) meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey)
meta(name="ol-recommendedCurrency" content=personalSubscription.recurly.currency) meta(name="ol-recommendedCurrency" content=personalSubscription.recurly.currency)

View file

@ -1,12 +0,0 @@
import { useTranslation } from 'react-i18next'
export default function ActionButtonText({
inflight,
buttonText,
}: {
inflight: boolean
buttonText: string
}) {
const { t } = useTranslation()
return <>{!inflight ? buttonText : t('processing_uppercase') + '…'}</>
}

View file

@ -1,5 +1,5 @@
import classNames from 'classnames'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import OLNotification from '@/features/ui/components/ol/ol-notification'
export default function GenericErrorAlert({ export default function GenericErrorAlert({
className, className,
@ -7,12 +7,18 @@ export default function GenericErrorAlert({
className?: string className?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const alertClassName = classNames('alert', 'alert-danger', className)
return ( return (
<div className={alertClassName} aria-live="polite"> <OLNotification
{t('generic_something_went_wrong')}. {t('try_again')}.{' '} className={className}
{t('generic_if_problem_continues_contact_us')}. aria-live="polite"
</div> type="error"
content={
<>
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
{t('generic_if_problem_continues_contact_us')}.
</>
}
/>
) )
} }

View file

@ -1,9 +1,9 @@
import { Button } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { MemberGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription' import { MemberGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context' import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import { LEAVE_GROUP_MODAL_ID } from './leave-group-modal' import { LEAVE_GROUP_MODAL_ID } from './leave-group-modal'
import getMeta from '../../../../utils/meta' import getMeta from '../../../../utils/meta'
import OLButton from '@/features/ui/components/ol/ol-button'
type GroupSubscriptionMembershipProps = { type GroupSubscriptionMembershipProps = {
subscription: MemberGroupSubscription subscription: MemberGroupSubscription
@ -52,9 +52,9 @@ export default function GroupSubscriptionMembership({
</span> </span>
) : ( ) : (
<span> <span>
<Button bsStyle="danger" onClick={leaveGroup}> <OLButton variant="danger" onClick={leaveGroup}>
{t('leave_group')} {t('leave_group')}
</Button> </OLButton>
</span> </span>
)} )}
<hr /> <hr />

View file

@ -1,6 +1,7 @@
import { Trans } from 'react-i18next' import { Trans } from 'react-i18next'
import { Institution } from '../../../../../../types/institution' import { Institution } from '../../../../../../types/institution'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context' import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import OLNotification from '@/features/ui/components/ol/ol-notification'
function InstitutionMemberships() { function InstitutionMemberships() {
const { institutionMemberships } = useSubscriptionDashboardContext() const { institutionMemberships } = useSubscriptionDashboardContext()
@ -9,13 +10,16 @@ function InstitutionMemberships() {
if (!institutionMemberships) { if (!institutionMemberships) {
return ( return (
<div className="alert alert-warning"> <OLNotification
<p> type="warning"
Sorry, something went wrong. Subscription information related to content={
institutional affiliations may not be displayed. Please try again <p>
later. Sorry, something went wrong. Subscription information related to
</p> institutional affiliations may not be displayed. Please try again
</div> later.
</p>
}
/>
) )
} }

View file

@ -1,11 +1,16 @@
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { Button, Modal } from 'react-bootstrap'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { deleteJSON } from '../../../../infrastructure/fetch-json' import { deleteJSON } from '../../../../infrastructure/fetch-json'
import AccessibleModal from '../../../../shared/components/accessible-modal'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context' import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import { useLocation } from '../../../../shared/hooks/use-location' import { useLocation } from '../../../../shared/hooks/use-location'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
export const LEAVE_GROUP_MODAL_ID = 'leave-group' export const LEAVE_GROUP_MODAL_ID = 'leave-group'
@ -37,38 +42,43 @@ export default function LeaveGroupModal() {
} }
return ( return (
<AccessibleModal <OLModal
id={LEAVE_GROUP_MODAL_ID} id={LEAVE_GROUP_MODAL_ID}
show show
animation animation
onHide={handleCloseModal} onHide={handleCloseModal}
backdrop="static" backdrop="static"
> >
<Modal.Header> <OLModalHeader>
<Modal.Title>{t('leave_group')}</Modal.Title> <OLModalTitle>{t('leave_group')}</OLModalTitle>
</Modal.Header> </OLModalHeader>
<Modal.Body> <OLModalBody>
<p>{t('sure_you_want_to_leave_group')}</p> <p>{t('sure_you_want_to_leave_group')}</p>
</Modal.Body> </OLModalBody>
<Modal.Footer> <OLModalFooter>
<Button <OLButton
bsStyle={null} variant="secondary"
className="btn-secondary"
onClick={handleCloseModal} onClick={handleCloseModal}
disabled={inflight} disabled={inflight}
> >
{t('cancel')} {t('cancel')}
</Button> </OLButton>
<Button <OLButton
bsStyle="danger" variant="danger"
onClick={handleConfirmLeaveGroup} onClick={handleConfirmLeaveGroup}
disabled={inflight} disabled={inflight}
isLoading={inflight}
bs3Props={{
loading: inflight
? t('processing_uppercase') + '…'
: t('leave_now'),
}}
> >
{inflight ? t('processing_uppercase') + '…' : t('leave_now')} {t('processing_uppercase')}
</Button> </OLButton>
</Modal.Footer> </OLModalFooter>
</AccessibleModal> </OLModal>
) )
} }

View file

@ -100,27 +100,29 @@ export default function ManagedGroupSubscriptions() {
<p> <p>
<ManagedGroupAdministrator subscription={subscription} /> <ManagedGroupAdministrator subscription={subscription} />
</p> </p>
<RowLink <ul className="list-group p-0">
href={`/manage/groups/${subscription._id}/members`} <RowLink
heading={t('manage_members')} href={`/manage/groups/${subscription._id}/members`}
subtext={t('manage_group_members_subtext')} heading={t('manage_members')}
icon="groups" subtext={t('manage_group_members_subtext')}
/> icon="groups"
<RowLink />
href={`/manage/groups/${subscription._id}/managers`} <RowLink
heading={t('manage_group_managers')} href={`/manage/groups/${subscription._id}/managers`}
subtext={t('manage_managers_subtext')} heading={t('manage_group_managers')}
icon="manage_accounts" subtext={t('manage_managers_subtext')}
/> icon="manage_accounts"
{groupSettingsEnabledFor?.includes(subscription._id) && ( />
<GroupSettingsButton subscription={subscription} /> {groupSettingsEnabledFor?.includes(subscription._id) && (
)} <GroupSettingsButton subscription={subscription} />
<RowLink )}
href={`/metrics/groups/${subscription._id}`} <RowLink
heading={t('view_metrics')} href={`/metrics/groups/${subscription._id}`}
subtext={t('view_metrics_group_subtext')} heading={t('view_metrics')}
icon="insights" subtext={t('view_metrics_group_subtext')}
/> icon="insights"
/>
</ul>
<hr /> <hr />
</div> </div>
) )

View file

@ -6,6 +6,7 @@ import { ManagedInstitution as Institution } from '../../../../../../types/subsc
import { RowLink } from './row-link' import { RowLink } from './row-link'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import getMeta from '@/utils/meta' import getMeta from '@/utils/meta'
import OLButton from '@/features/ui/components/ol/ol-button'
type ManagedInstitutionProps = { type ManagedInstitutionProps = {
institution: Institution institution: Institution
@ -53,33 +54,35 @@ export default function ManagedInstitution({
tOptions={{ interpolation: { escapeValue: true } }} tOptions={{ interpolation: { escapeValue: true } }}
/> />
</p> </p>
<RowLink <ul className="list-group p-0">
href={`/metrics/institutions/${institution.v1Id}`} <RowLink
heading={t('view_metrics')} href={`/metrics/institutions/${institution.v1Id}`}
subtext={t('view_metrics_commons_subtext')} heading={t('view_metrics')}
icon="insights" subtext={t('view_metrics_commons_subtext')}
/> icon="insights"
<RowLink />
href={`/institutions/${institution.v1Id}/hub`} <RowLink
heading={t('view_hub')} href={`/institutions/${institution.v1Id}/hub`}
subtext={t('view_hub_subtext')} heading={t('view_hub')}
icon="account_circle" subtext={t('view_hub_subtext')}
/> icon="account_circle"
<RowLink />
href={`/manage/institutions/${institution.v1Id}/managers`} <RowLink
heading={t('manage_institution_managers')} href={`/manage/institutions/${institution.v1Id}/managers`}
subtext={t('manage_managers_subtext')} heading={t('manage_institution_managers')}
icon="manage_accounts" subtext={t('manage_managers_subtext')}
/> icon="manage_accounts"
/>
</ul>
<div> <div>
<p> <p>
<span>Monthly metrics emails: </span> <span>Monthly metrics emails: </span>
{subscriptionChanging ? ( {subscriptionChanging ? (
<i className="fa fa-spin fa-refresh" /> <i className="fa fa-spin fa-refresh" />
) : ( ) : (
<button <OLButton
variant="link"
className="btn-inline-link" className="btn-inline-link"
style={{ border: 0 }}
onClick={e => onClick={e =>
changeInstitutionalEmailSubscription(e, institution.v1Id) changeInstitutionalEmailSubscription(e, institution.v1Id)
} }
@ -89,7 +92,7 @@ export default function ManagedInstitution({
) )
? t('subscribe') ? t('subscribe')
: t('unsubscribe')} : t('unsubscribe')}
</button> </OLButton>
)} )}
</p> </p>
</div> </div>

View file

@ -22,18 +22,20 @@ export default function ManagedPublisher({ publisher }: ManagedPublisherProps) {
tOptions={{ interpolation: { escapeValue: true } }} tOptions={{ interpolation: { escapeValue: true } }}
/> />
</p> </p>
<RowLink <ul className="list-group p-0">
href={`/publishers/${publisher.slug}/hub`} <RowLink
heading={t('view_hub')} href={`/publishers/${publisher.slug}/hub`}
subtext={t('view_hub_subtext')} heading={t('view_hub')}
icon="account_circle" subtext={t('view_hub_subtext')}
/> icon="account_circle"
<RowLink />
href={`/manage/publishers/${publisher.slug}/managers`} <RowLink
heading={t('manage_publisher_managers')} href={`/manage/publishers/${publisher.slug}/managers`}
subtext={t('manage_managers_subtext')} heading={t('manage_publisher_managers')}
icon="manage_accounts" subtext={t('manage_managers_subtext')}
/> icon="manage_accounts"
/>
</ul>
<hr /> <hr />
</div> </div>
) )

View file

@ -1,9 +1,11 @@
import { useTranslation, Trans } from 'react-i18next' import { useTranslation, Trans } from 'react-i18next'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context' import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import { FormGroup, Alert } from 'react-bootstrap'
import getMeta from '../../../../utils/meta' import getMeta from '../../../../utils/meta'
import useAsync from '../../../../shared/hooks/use-async' import useAsync from '../../../../shared/hooks/use-async'
import { postJSON } from '../../../../infrastructure/fetch-json' import { postJSON } from '../../../../infrastructure/fetch-json'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
function PersonalSubscriptionRecurlySyncEmail() { function PersonalSubscriptionRecurlySyncEmail() {
const { t } = useTranslation() const { t } = useTranslation()
@ -25,9 +27,12 @@ function PersonalSubscriptionRecurlySyncEmail() {
return ( return (
<> <>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<FormGroup> <OLFormGroup>
{isSuccess ? ( {isSuccess ? (
<Alert bsStyle="success">{t('recurly_email_updated')}</Alert> <OLNotification
type="success"
content={t('recurly_email_updated')}
/>
) : ( ) : (
<> <>
<p> <p>
@ -40,17 +45,21 @@ function PersonalSubscriptionRecurlySyncEmail() {
/> />
</p> </p>
<div> <div>
<button <OLButton
className="btn btn-primary" variant="primary"
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
isLoading={isLoading}
bs3Props={{
loading: isLoading ? t('updating') + '…' : t('update'),
}}
> >
{isLoading ? <>{t('updating')}</> : t('update')} {t('update')}
</button> </OLButton>
</div> </div>
</> </>
)} )}
</FormGroup> </OLFormGroup>
</form> </form>
<hr /> <hr />
</> </>

View file

@ -5,6 +5,7 @@ import { CanceledSubscription } from './states/canceled'
import { ExpiredSubscription } from './states/expired' import { ExpiredSubscription } from './states/expired'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context' import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import PersonalSubscriptionRecurlySyncEmail from './personal-subscription-recurly-sync-email' import PersonalSubscriptionRecurlySyncEmail from './personal-subscription-recurly-sync-email'
import OLNotification from '@/features/ui/components/ol/ol-notification'
function PastDueSubscriptionAlert({ function PastDueSubscriptionAlert({
subscription, subscription,
@ -13,18 +14,21 @@ function PastDueSubscriptionAlert({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<> <OLNotification
<div className="alert alert-danger" role="alert"> type="error"
{t('account_has_past_due_invoice_change_plan_warning')}{' '} content={
<a <>
href={subscription.recurly.accountManagementLink} {t('account_has_past_due_invoice_change_plan_warning')}{' '}
target="_blank" <a
rel="noreferrer noopener" href={subscription.recurly.accountManagementLink}
> target="_blank"
{t('view_your_invoices')} rel="noreferrer noopener"
</a> >
</div> {t('view_your_invoices')}
</> </a>
</>
}
/>
) )
} }
@ -75,9 +79,10 @@ function PersonalSubscription() {
subscription={personalSubscription as RecurlySubscription} subscription={personalSubscription as RecurlySubscription}
/> />
{recurlyLoadError && ( {recurlyLoadError && (
<div className="alert alert-warning" role="alert"> <OLNotification
<strong>{t('payment_provider_unreachable_error')}</strong> type="warning"
</div> content={<strong>{t('payment_provider_unreachable_error')}</strong>}
/>
)} )}
<hr /> <hr />
<PersonalSubscriptionRecurlySyncEmail /> <PersonalSubscriptionRecurlySyncEmail />

View file

@ -5,6 +5,7 @@ import useAsync from '../../../../shared/hooks/use-async'
import { useLocation } from '../../../../shared/hooks/use-location' import { useLocation } from '../../../../shared/hooks/use-location'
import getMeta from '../../../../utils/meta' import getMeta from '../../../../utils/meta'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import OLButton from '@/features/ui/components/ol/ol-button'
function ReactivateSubscription() { function ReactivateSubscription() {
const { t } = useTranslation() const { t } = useTranslation()
@ -25,14 +26,14 @@ function ReactivateSubscription() {
} }
return ( return (
<button <OLButton
type="button" variant="primary"
className="btn btn-primary"
disabled={isLoading || isSuccess} disabled={isLoading || isSuccess}
onClick={handleReactivate} onClick={handleReactivate}
isLoading={isLoading}
> >
{t('reactivate_subscription')} {t('reactivate_subscription')}
</button> </OLButton>
) )
} }

View file

@ -1,4 +1,5 @@
import MaterialIcon from '../../../../shared/components/material-icon' import MaterialIcon from '../../../../shared/components/material-icon'
import { isBootstrap5 } from '@/features/utils/bootstrap-5'
type RowLinkProps = { type RowLinkProps = {
href: string href: string
@ -7,7 +8,11 @@ type RowLinkProps = {
icon: string icon: string
} }
export function RowLink({ href, heading, subtext, icon }: RowLinkProps) { export function RowLink(props: RowLinkProps) {
return isBootstrap5() ? <BS5RowLink {...props} /> : <BS3RowLink {...props} />
}
function BS3RowLink({ href, heading, subtext, icon }: RowLinkProps) {
return ( return (
<a href={href} className="row-link"> <a href={href} className="row-link">
<div className="icon"> <div className="icon">
@ -23,3 +28,18 @@ export function RowLink({ href, heading, subtext, icon }: RowLinkProps) {
</a> </a>
) )
} }
function BS5RowLink({ href, heading, subtext, icon }: RowLinkProps) {
return (
<li className="list-group-item row-link">
<a href={href}>
<MaterialIcon type={icon} className="p-2 p-md-3" />
<div className="flex-grow-1">
<strong>{heading}</strong>
<div>{subtext}</div>
</div>
<MaterialIcon type="keyboard_arrow_right" className="p-2 p-md-3" />
</a>
</li>
)
}

View file

@ -14,6 +14,7 @@ import { ChangePlanModal } from './change-plan/modals/change-plan-modal'
import { ConfirmChangePlanModal } from './change-plan/modals/confirm-change-plan-modal' import { ConfirmChangePlanModal } from './change-plan/modals/confirm-change-plan-modal'
import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal' import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal' import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
export function ActiveSubscription({ export function ActiveSubscription({
subscription, subscription,
@ -62,12 +63,13 @@ export function ActiveSubscription({
subscription.recurly.account.has_past_due_invoice._ !== 'true' && ( subscription.recurly.account.has_past_due_invoice._ !== 'true' && (
<> <>
{' '} {' '}
<button <OLButton
variant="link"
className="btn-inline-link" className="btn-inline-link"
onClick={() => setModalIdShown('change-plan')} onClick={() => setModalIdShown('change-plan')}
> >
{t('change_plan')} {t('change_plan')}
</button> </OLButton>
</> </>
)} )}
</p> </p>
@ -103,7 +105,7 @@ export function ActiveSubscription({
/> />
</p> </p>
<PriceExceptions subscription={subscription} /> <PriceExceptions subscription={subscription} />
<p> <p className="d-inline-flex flex-wrap gap-1">
<a <a
href={subscription.recurly.billingDetailsLink} href={subscription.recurly.billingDetailsLink}
target="_blank" target="_blank"
@ -121,7 +123,10 @@ export function ActiveSubscription({
{t('view_your_invoices')} {t('view_your_invoices')}
</a> </a>
{!recurlyLoadError && ( {!recurlyLoadError && (
<CancelSubscriptionButton className="btn btn-danger-ghost ms-1" /> <>
{' '}
<CancelSubscriptionButton />
</>
)} )}
</p> </p>

View file

@ -9,41 +9,40 @@ import {
redirectAfterCancelSubscriptionUrl, redirectAfterCancelSubscriptionUrl,
} from '../../../../../data/subscription-url' } from '../../../../../data/subscription-url'
import showDowngradeOption from '../../../../../util/show-downgrade-option' import showDowngradeOption from '../../../../../util/show-downgrade-option'
import ActionButtonText from '../../../action-button-text'
import GenericErrorAlert from '../../../generic-error-alert' import GenericErrorAlert from '../../../generic-error-alert'
import DowngradePlanButton from './downgrade-plan-button' import DowngradePlanButton from './downgrade-plan-button'
import ExtendTrialButton from './extend-trial-button' import ExtendTrialButton from './extend-trial-button'
import { useLocation } from '../../../../../../../shared/hooks/use-location' import { useLocation } from '../../../../../../../shared/hooks/use-location'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import OLButton from '@/features/ui/components/ol/ol-button'
const planCodeToDowngradeTo = 'paid-personal' const planCodeToDowngradeTo = 'paid-personal'
function ConfirmCancelSubscriptionButton({ function ConfirmCancelSubscriptionButton({
buttonClass, showNoThanks,
buttonText, onClick,
handleCancelSubscription, disabled,
isLoadingCancel, isLoading,
isSuccessCancel,
isButtonDisabled,
}: { }: {
buttonClass: string showNoThanks: boolean
buttonText: string onClick: () => void
handleCancelSubscription: () => void disabled: boolean
isLoadingCancel: boolean isLoading: boolean
isSuccessCancel: boolean
isButtonDisabled: boolean
}) { }) {
const { t } = useTranslation()
const text = showNoThanks ? t('no_thanks_cancel_now') : t('cancel_my_account')
return ( return (
<button <OLButton
className={`btn ${buttonClass}`} isLoading={isLoading}
onClick={handleCancelSubscription} disabled={disabled}
disabled={isButtonDisabled} onClick={onClick}
className={showNoThanks ? 'btn-inline-link' : undefined}
bs3Props={{
loading: isLoading ? t('processing_uppercase') + '…' : text,
}}
> >
<ActionButtonText {text}
inflight={isSuccessCancel || isLoadingCancel} </OLButton>
buttonText={buttonText}
/>
</button>
) )
} }
@ -85,8 +84,7 @@ function NotCancelOption({
<p> <p>
<ExtendTrialButton <ExtendTrialButton
isButtonDisabled={isButtonDisabled} isButtonDisabled={isButtonDisabled}
isLoadingSecondaryAction={isLoadingSecondaryAction} isLoading={isLoadingSecondaryAction || isSuccessSecondaryAction}
isSuccessSecondaryAction={isSuccessSecondaryAction}
runAsyncSecondaryAction={runAsyncSecondaryAction} runAsyncSecondaryAction={runAsyncSecondaryAction}
/> />
</p> </p>
@ -114,8 +112,7 @@ function NotCancelOption({
<p> <p>
<DowngradePlanButton <DowngradePlanButton
isButtonDisabled={isButtonDisabled} isButtonDisabled={isButtonDisabled}
isLoadingSecondaryAction={isLoadingSecondaryAction} isLoading={isLoadingSecondaryAction || isSuccessSecondaryAction}
isSuccessSecondaryAction={isSuccessSecondaryAction}
planToDowngradeTo={planToDowngradeTo} planToDowngradeTo={planToDowngradeTo}
runAsyncSecondaryAction={runAsyncSecondaryAction} runAsyncSecondaryAction={runAsyncSecondaryAction}
/> />
@ -130,12 +127,9 @@ function NotCancelOption({
return ( return (
<p> <p>
<button <OLButton variant="secondary" onClick={handleKeepPlan}>
className="btn btn-secondary-info btn-secondary"
onClick={handleKeepPlan}
>
{t('i_want_to_stay')} {t('i_want_to_stay')}
</button> </OLButton>
</p> </p>
) )
} }
@ -188,13 +182,6 @@ export function CancelSubscription() {
const showExtendFreeTrial = userCanExtendTrial const showExtendFreeTrial = userCanExtendTrial
let confirmCancelButtonText = t('cancel_my_account')
let confirmCancelButtonClass = 'btn-primary'
if (showExtendFreeTrial || showDowngrade) {
confirmCancelButtonText = t('no_thanks_cancel_now')
confirmCancelButtonClass = 'btn-inline-link'
}
return ( return (
<div className="text-center"> <div className="text-center">
<p> <p>
@ -214,12 +201,10 @@ export function CancelSubscription() {
/> />
<ConfirmCancelSubscriptionButton <ConfirmCancelSubscriptionButton
buttonClass={confirmCancelButtonClass} showNoThanks={showExtendFreeTrial || showDowngrade}
buttonText={confirmCancelButtonText} onClick={handleCancelSubscription}
isButtonDisabled={isButtonDisabled} disabled={isButtonDisabled}
handleCancelSubscription={handleCancelSubscription} isLoading={isSuccessCancel || isLoadingCancel}
isSuccessCancel={isSuccessCancel}
isLoadingCancel={isLoadingCancel}
/> />
</div> </div>
) )

View file

@ -2,20 +2,18 @@ import { useTranslation } from 'react-i18next'
import { Plan } from '../../../../../../../../../types/subscription/plan' import { Plan } from '../../../../../../../../../types/subscription/plan'
import { postJSON } from '../../../../../../../infrastructure/fetch-json' import { postJSON } from '../../../../../../../infrastructure/fetch-json'
import { subscriptionUpdateUrl } from '../../../../../data/subscription-url' import { subscriptionUpdateUrl } from '../../../../../data/subscription-url'
import ActionButtonText from '../../../action-button-text'
import { useLocation } from '../../../../../../../shared/hooks/use-location' import { useLocation } from '../../../../../../../shared/hooks/use-location'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import OLButton from '@/features/ui/components/ol/ol-button'
export default function DowngradePlanButton({ export default function DowngradePlanButton({
isButtonDisabled, isButtonDisabled,
isLoadingSecondaryAction, isLoading,
isSuccessSecondaryAction,
planToDowngradeTo, planToDowngradeTo,
runAsyncSecondaryAction, runAsyncSecondaryAction,
}: { }: {
isButtonDisabled: boolean isButtonDisabled: boolean
isLoadingSecondaryAction: boolean isLoading: boolean
isSuccessSecondaryAction: boolean
planToDowngradeTo: Plan planToDowngradeTo: Plan
runAsyncSecondaryAction: (promise: Promise<unknown>) => Promise<unknown> runAsyncSecondaryAction: (promise: Promise<unknown>) => Promise<unknown>
}) { }) {
@ -37,17 +35,16 @@ export default function DowngradePlanButton({
} }
return ( return (
<> <OLButton
<button variant="primary"
className="btn btn-primary" onClick={handleDowngradePlan}
onClick={handleDowngradePlan} disabled={isButtonDisabled}
disabled={isButtonDisabled} isLoading={isLoading}
> bs3Props={{
<ActionButtonText loading: isLoading ? t('processing_uppercase') + '…' : buttonText,
inflight={isLoadingSecondaryAction || isSuccessSecondaryAction} }}
buttonText={buttonText} >
/> {buttonText}
</button> </OLButton>
</>
) )
} }

View file

@ -1,19 +1,17 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { putJSON } from '../../../../../../../infrastructure/fetch-json' import { putJSON } from '../../../../../../../infrastructure/fetch-json'
import { extendTrialUrl } from '../../../../../data/subscription-url' import { extendTrialUrl } from '../../../../../data/subscription-url'
import ActionButtonText from '../../../action-button-text'
import { useLocation } from '../../../../../../../shared/hooks/use-location' import { useLocation } from '../../../../../../../shared/hooks/use-location'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import OLButton from '@/features/ui/components/ol/ol-button'
export default function ExtendTrialButton({ export default function ExtendTrialButton({
isButtonDisabled, isButtonDisabled,
isLoadingSecondaryAction, isLoading,
isSuccessSecondaryAction,
runAsyncSecondaryAction, runAsyncSecondaryAction,
}: { }: {
isButtonDisabled: boolean isButtonDisabled: boolean
isLoadingSecondaryAction: boolean isLoading: boolean
isSuccessSecondaryAction: boolean
runAsyncSecondaryAction: (promise: Promise<unknown>) => Promise<unknown> runAsyncSecondaryAction: (promise: Promise<unknown>) => Promise<unknown>
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -30,17 +28,16 @@ export default function ExtendTrialButton({
} }
return ( return (
<> <OLButton
<button variant="primary"
className="btn btn-primary" onClick={handleExtendTrial}
onClick={handleExtendTrial} disabled={isButtonDisabled}
disabled={isButtonDisabled} isLoading={isLoading}
> bs3Props={{
<ActionButtonText loading: isLoading ? t('processing_uppercase') + '…' : buttonText,
inflight={isLoadingSecondaryAction || isSuccessSecondaryAction} }}
buttonText={buttonText} >
/> {buttonText}
</button> </OLButton>
</>
) )
} }

View file

@ -1,10 +1,9 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import * as eventTracking from '../../../../../../infrastructure/event-tracking' import * as eventTracking from '../../../../../../infrastructure/event-tracking'
import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context' import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
import OLButton from '@/features/ui/components/ol/ol-button'
export function CancelSubscriptionButton( export function CancelSubscriptionButton() {
props: React.ComponentProps<'button'>
) {
const { t } = useTranslation() const { t } = useTranslation()
const { recurlyLoadError, setShowCancellation } = const { recurlyLoadError, setShowCancellation } =
useSubscriptionDashboardContext() useSubscriptionDashboardContext()
@ -17,8 +16,8 @@ export function CancelSubscriptionButton(
if (recurlyLoadError) return null if (recurlyLoadError) return null
return ( return (
<button onClick={handleCancelSubscriptionClick} {...props}> <OLButton variant="danger-ghost" onClick={handleCancelSubscriptionClick}>
{t('cancel_your_subscription')} {t('cancel_your_subscription')}
</button> </OLButton>
) )
} }

View file

@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context' import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
import OLButton from '@/features/ui/components/ol/ol-button'
export function ChangeToGroupPlan() { export function ChangeToGroupPlan() {
const { t } = useTranslation() const { t } = useTranslation()
@ -10,13 +11,13 @@ export function ChangeToGroupPlan() {
} }
return ( return (
<div className="card-gray text-center mt-3"> <div className="card-gray text-center mt-3 p-3">
<h2 style={{ marginTop: 0 }}>{t('looking_multiple_licenses')}</h2> <h2 style={{ marginTop: 0 }}>{t('looking_multiple_licenses')}</h2>
<p style={{ margin: 0 }}>{t('reduce_costs_group_licenses')}</p> <p style={{ margin: 0 }}>{t('reduce_costs_group_licenses')}</p>
<br /> <br />
<button className="btn btn-primary" onClick={handleClick}> <OLButton variant="primary" onClick={handleClick}>
{t('change_to_group_plan')} {t('change_to_group_plan')}
</button> </OLButton>
</div> </div>
) )
} }

View file

@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'
import { Plan } from '../../../../../../../../../types/subscription/plan' import { Plan } from '../../../../../../../../../types/subscription/plan'
import Icon from '../../../../../../../shared/components/icon' import Icon from '../../../../../../../shared/components/icon'
import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context' import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
import OLButton from '@/features/ui/components/ol/ol-button'
function ChangeToPlanButton({ planCode }: { planCode: string }) { function ChangeToPlanButton({ planCode }: { planCode: string }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -12,9 +13,9 @@ function ChangeToPlanButton({ planCode }: { planCode: string }) {
} }
return ( return (
<button className="btn btn-primary" onClick={handleClick}> <OLButton variant="primary" onClick={handleClick}>
{t('change_to_this_plan')} {t('change_to_this_plan')}
</button> </OLButton>
) )
} }
@ -27,9 +28,9 @@ function KeepCurrentPlanButton({ plan }: { plan: Plan }) {
} }
return ( return (
<button className="btn btn-primary" onClick={handleClick}> <OLButton variant="primary" onClick={handleClick}>
{t('keep_current_plan')} {t('keep_current_plan')}
</button> </OLButton>
) )
} }
@ -66,13 +67,13 @@ function PlansRow({ plan }: { plan: Plan }) {
return ( return (
<tr> <tr>
<td> <td className="align-middle">
<strong>{plan.name}</strong> <strong>{plan.name}</strong>
</td> </td>
<td> <td className="align-middle">
{plan.displayPrice} / {plan.annual ? t('year') : t('month')} {plan.displayPrice} / {plan.annual ? t('year') : t('month')}
</td> </td>
<td> <td className="align-middle text-center">
<ChangePlanButton plan={plan} /> <ChangePlanButton plan={plan} />
</td> </td>
</tr> </tr>
@ -97,9 +98,9 @@ export function IndividualPlansTable({ plans }: { plans: Array<Plan> }) {
if (!plans || recurlyLoadError) return null if (!plans || recurlyLoadError) return null
return ( return (
<table className="table table-vertically-centered-cells"> <table className="table align-middle table-vertically-centered-cells m-0">
<thead> <thead>
<tr> <tr className="d-none d-md-table-row">
<th>{t('name')}</th> <th>{t('name')}</th>
<th>{t('price')}</th> <th>{t('price')}</th>
<th /> <th />

View file

@ -1,11 +1,14 @@
import { Modal } from 'react-bootstrap'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids' import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
import AccessibleModal from '../../../../../../../../shared/components/accessible-modal'
import LoadingSpinner from '../../../../../../../../shared/components/loading-spinner' import LoadingSpinner from '../../../../../../../../shared/components/loading-spinner'
import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context' import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
import { ChangeToGroupPlan } from '../change-to-group-plan' import { ChangeToGroupPlan } from '../change-to-group-plan'
import { IndividualPlansTable } from '../individual-plans-table' import { IndividualPlansTable } from '../individual-plans-table'
import OLModal, {
OLModalBody,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
function ChangePlanOptions() { function ChangePlanOptions() {
const { plans, queryingIndividualPlansData, recurlyLoadError } = const { plans, queryingIndividualPlansData, recurlyLoadError } =
@ -22,7 +25,7 @@ function ChangePlanOptions() {
} else { } else {
return ( return (
<> <>
<div className="table-outlined-container"> <div className="border rounded px-2 pt-1 table-outlined-container">
<IndividualPlansTable plans={plans} /> <IndividualPlansTable plans={plans} />
</div> </div>
<ChangeToGroupPlan /> <ChangeToGroupPlan />
@ -39,14 +42,14 @@ export function ChangePlanModal() {
if (modalIdShown !== modalId) return null if (modalIdShown !== modalId) return null
return ( return (
<AccessibleModal id={modalId} show animation onHide={handleCloseModal}> <OLModal id={modalId} show animation onHide={handleCloseModal} size="lg">
<Modal.Header closeButton> <OLModalHeader closeButton>
<Modal.Title>{t('change_plan')}</Modal.Title> <OLModalTitle>{t('change_plan')}</OLModalTitle>
</Modal.Header> </OLModalHeader>
<Modal.Body> <OLModalBody>
<ChangePlanOptions /> <ChangePlanOptions />
</Modal.Body> </OLModalBody>
</AccessibleModal> </OLModal>
) )
} }

View file

@ -1,16 +1,29 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Modal } from 'react-bootstrap'
import { useTranslation, Trans } from 'react-i18next' import { useTranslation, Trans } from 'react-i18next'
import { Subscription } from '../../../../../../../../../../types/subscription/dashboard/subscription' import { Subscription } from '../../../../../../../../../../types/subscription/dashboard/subscription'
import { PriceForDisplayData } from '../../../../../../../../../../types/subscription/plan' import { PriceForDisplayData } from '../../../../../../../../../../types/subscription/plan'
import { postJSON } from '../../../../../../../../infrastructure/fetch-json' import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
import AccessibleModal from '../../../../../../../../shared/components/accessible-modal'
import getMeta from '../../../../../../../../utils/meta' import getMeta from '../../../../../../../../utils/meta'
import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context' import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
import GenericErrorAlert from '../../../../generic-error-alert' import GenericErrorAlert from '../../../../generic-error-alert'
import { subscriptionUpdateUrl } from '../../../../../../data/subscription-url' import { subscriptionUpdateUrl } from '../../../../../../data/subscription-url'
import { getRecurlyGroupPlanCode } from '../../../../../../util/recurly-group-plan-code' import { getRecurlyGroupPlanCode } from '../../../../../../util/recurly-group-plan-code'
import { useLocation } from '../../../../../../../../shared/hooks/use-location' import { useLocation } from '../../../../../../../../shared/hooks/use-location'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
import { useContactUsModal } from '@/shared/hooks/use-contact-us-modal'
import { UserProvider } from '@/shared/context/user-context'
import OLButton from '@/features/ui/components/ol/ol-button'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import OLNotification from '@/features/ui/components/ol/ol-notification'
const educationalPercentDiscount = 40 const educationalPercentDiscount = 40
const groupSizeForEducationalDiscount = 10 const groupSizeForEducationalDiscount = 10
@ -88,7 +101,7 @@ function GroupPrice({
})} })}
</span> </span>
<br /> <BootstrapVersionSwitcher bs3={<br />} />
<span className="circle-subtext"> <span className="circle-subtext">
<span aria-hidden> <span aria-hidden>
@ -124,6 +137,8 @@ export function ChangeToGroupModal() {
setGroupPlanToChangeToSize, setGroupPlanToChangeToSize,
setGroupPlanToChangeToUsage, setGroupPlanToChangeToUsage,
} = useSubscriptionDashboardContext() } = useSubscriptionDashboardContext()
const { modal: contactModal, showModal: showContactModal } =
useContactUsModal({ autofillProjectUrl: false })
const groupPlans = getMeta('ol-groupPlans') const groupPlans = getMeta('ol-groupPlans')
const personalSubscription = getMeta('ol-subscription') as Subscription const personalSubscription = getMeta('ol-subscription') as Subscription
const [error, setError] = useState(false) const [error, setError] = useState(false)
@ -157,11 +172,6 @@ export function ChangeToGroupModal() {
} }
}, [personalSubscription, setGroupPlanToChangeToCode]) }, [personalSubscription, setGroupPlanToChangeToCode])
function handleGetInTouchButton() {
handleCloseModal()
$('[data-ol-contact-form-modal="contact-us"]').modal()
}
if ( if (
modalIdShown !== modalId || modalIdShown !== modalId ||
!groupPlans || !groupPlans ||
@ -172,197 +182,199 @@ export function ChangeToGroupModal() {
return null return null
return ( return (
<AccessibleModal <>
id={modalId} <UserProvider>{contactModal}</UserProvider>
show <OLModal
animation id={modalId}
onHide={handleCloseModal} show
backdrop="static" animation
> onHide={handleCloseModal}
<Modal.Header> backdrop="static"
<button className="close" onClick={handleCloseModal}> >
<span aria-hidden="true">×</span> <OLModalHeader closeButton>
<span className="sr-only">{t('close')}</span> <OLModalTitle className="lh-sm">
</button> {t('customize_your_group_subscription')}
<div className="modal-title"> <br />
<h2>{t('customize_your_group_subscription')}</h2> <span className="h5">
<h3> {t('save_x_percent_or_more', {
{t('save_x_percent_or_more', { percent: '30',
percent: '30', })}
})} </span>
</h3> </OLModalTitle>
</div> </OLModalHeader>
</Modal.Header>
<Modal.Body> <OLModalBody>
<div className="container-fluid plans group-subscription-modal"> <div className="container-fluid plans group-subscription-modal">
{groupPlanToChangeToPriceError && <GenericErrorAlert />} {groupPlanToChangeToPriceError && <GenericErrorAlert />}
<div className="row"> <div className="row">
<div className="col-md-6 text-center"> <div className="col-md-6 text-center">
<div className="circle circle-lg"> <div className="circle circle-lg mb-4 mx-auto">
<GroupPrice <GroupPrice
groupPlanToChangeToPrice={groupPlanToChangeToPrice} groupPlanToChangeToPrice={groupPlanToChangeToPrice}
queryingGroupPlanToChangeToPrice={ queryingGroupPlanToChangeToPrice={
queryingGroupPlanToChangeToPrice queryingGroupPlanToChangeToPrice
} }
/> />
</div>
<p>{t('each_user_will_have_access_to')}:</p>
<ul className="list-unstyled">
<li className="mb-3">
<strong>
<GroupPlanCollaboratorCount
planCode={groupPlanToChangeToCode}
/>
</strong>
</li>
<li>
<strong>{t('all_premium_features')}</strong>
</li>
<li>{t('sync_dropbox_github')}</li>
<li>{t('full_doc_history')}</li>
<li>{t('track_changes')}</li>
<li>
<span aria-hidden>+ {t('more').toLowerCase()}</span>
<span className="sr-only">{t('plus_more')}</span>
</li>
</ul>
</div> </div>
<p>{t('each_user_will_have_access_to')}:</p>
<ul className="list-unstyled">
<li className="list-item-with-margin-bottom">
<strong>
<GroupPlanCollaboratorCount
planCode={groupPlanToChangeToCode}
/>
</strong>
</li>
<li>
<strong>{t('all_premium_features')}</strong>
</li>
<li>{t('sync_dropbox_github')}</li>
<li>{t('full_doc_history')}</li>
<li>{t('track_changes')}</li>
<li>
<span aria-hidden>+ {t('more').toLowerCase()}</span>
<span className="sr-only">{t('plus_more')}</span>
</li>
</ul>
</div>
<div className="col-md-6"> <div className="col-md-6">
<form className="form"> <form className="form">
<fieldset className="form-group"> <fieldset className="form-group">
<legend className="legend-as-label">{t('plan')}</legend> <legend className="legend-as-label">{t('plan')}</legend>
{groupPlans.plans.map(option => ( {groupPlans.plans.map(option => (
<label <OLFormCheckbox
htmlFor={`plan-option-${option.code}`} key={option.code}
key={option.code}
className="group-plan-option"
>
<input
type="radio" type="radio"
name="plan-code" name="plan-code"
value={option.code} value={option.code}
id={`plan-option-${option.code}`} id={`plan-option-${option.code}`}
onChange={e => onChange={() => setGroupPlanToChangeToCode(option.code)}
setGroupPlanToChangeToCode(e.target.value)
}
checked={option.code === groupPlanToChangeToCode} checked={option.code === groupPlanToChangeToCode}
label={option.display}
/> />
<span>{option.display}</span>
</label>
))}
</fieldset>
<div className="form-group">
<label htmlFor="size">{t('number_of_users')}</label>
<select
name="size"
id="size"
className="form-control"
value={groupPlanToChangeToSize}
onChange={e => setGroupPlanToChangeToSize(e.target.value)}
>
{groupPlans.sizes.map(size => (
<option key={`size-option-${size}`}>{size}</option>
))} ))}
</select> </fieldset>
</div>
<div className="form-group"> <OLFormGroup controlId="size">
<strong> <OLFormLabel>{t('number_of_users')}</OLFormLabel>
{t('percent_discount_for_groups', { <OLFormSelect
percent: educationalPercentDiscount, name="size"
size: groupSizeForEducationalDiscount, value={groupPlanToChangeToSize}
})} onChange={e => setGroupPlanToChangeToSize(e.target.value)}
</strong> >
</div> {groupPlans.sizes.map(size => (
<option key={`size-option-${size}`}>{size}</option>
))}
</OLFormSelect>
</OLFormGroup>
<div className="form-group group-plan-option"> <OLFormGroup>
<label htmlFor="usage"> <strong>
<input {t('percent_discount_for_groups', {
id="usage" percent: educationalPercentDiscount,
type="checkbox" size: groupSizeForEducationalDiscount,
autoComplete="off" })}
checked={groupPlanToChangeToUsage === 'educational'} </strong>
onChange={e => { </OLFormGroup>
if (e.target.checked) {
setGroupPlanToChangeToUsage('educational') <OLFormCheckbox
} else { id="usage"
setGroupPlanToChangeToUsage('enterprise') type="checkbox"
} autoComplete="off"
}} checked={groupPlanToChangeToUsage === 'educational'}
/> onChange={e => {
<span>{t('license_for_educational_purposes')}</span> if (e.target.checked) {
</label> setGroupPlanToChangeToUsage('educational')
</div> } else {
</form> setGroupPlanToChangeToUsage('enterprise')
</div> }
</div> }}
<div className="row"> label={t('license_for_educational_purposes')}
<div className="col-md-12 text-center">
<div className="educational-discount-badge">
{groupPlanToChangeToUsage === 'educational' && (
<EducationDiscountAppliedOrNot
groupSize={groupPlanToChangeToSize}
/> />
)} </form>
</div> </div>
</div> </div>
</div> <div className="educational-discount-badge pt-4 text-center">
</div> {groupPlanToChangeToUsage === 'educational' && (
</Modal.Body> <EducationDiscountAppliedOrNot
groupSize={groupPlanToChangeToSize}
<Modal.Footer> />
<div className="text-center"> )}
{groupPlanToChangeToPrice?.includesTax && (
<p>
<Trans
i18nKey="total_with_subtotal_and_tax"
values={{
total: groupPlanToChangeToPrice.totalForDisplay,
subtotal: groupPlanToChangeToPrice.subtotal,
tax: groupPlanToChangeToPrice.tax,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
/* eslint-disable-next-line react/jsx-key */
<strong />,
]}
/>
</p>
)}
<p>
<strong>{t('new_subscription_will_be_billed_immediately')}</strong>
</p>
<hr className="thin" />
{error && (
<div className="alert alert-danger" aria-live="polite">
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
{t('generic_if_problem_continues_contact_us')}.
</div> </div>
)} </div>
<button </OLModalBody>
className="btn btn-primary btn-lg"
disabled={ <OLModalFooter>
queryingGroupPlanToChangeToPrice || <div className="text-center">
!groupPlanToChangeToPrice || {groupPlanToChangeToPrice?.includesTax && (
inflight <p>
} <Trans
onClick={upgrade} i18nKey="total_with_subtotal_and_tax"
> values={{
{!inflight ? t('upgrade_now') : t('processing_uppercase') + '…'} total: groupPlanToChangeToPrice.totalForDisplay,
</button> subtotal: groupPlanToChangeToPrice.subtotal,
<hr className="thin" /> tax: groupPlanToChangeToPrice.tax,
<button className="btn-inline-link" onClick={handleGetInTouchButton}> }}
{t('need_more_than_x_licenses', { shouldUnescape
x: 50, tOptions={{ interpolation: { escapeValue: true } }}
})}{' '} components={[
{t('please_get_in_touch')} /* eslint-disable-next-line react/jsx-key */
</button> <strong />,
</div> ]}
</Modal.Footer> />
</AccessibleModal> </p>
)}
<p>
<strong>
{t('new_subscription_will_be_billed_immediately')}
</strong>
</p>
<hr className="thin my-3" />
{error && (
<OLNotification
type="error"
aria-live="polite"
content={
<>
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
{t('generic_if_problem_continues_contact_us')}.
</>
}
/>
)}
<OLButton
variant="primary"
size="large"
disabled={
queryingGroupPlanToChangeToPrice ||
!groupPlanToChangeToPrice ||
inflight
}
onClick={upgrade}
isLoading={inflight}
bs3Props={{
loading: inflight
? t('processing_uppercase') + '…'
: t('upgrade_now'),
}}
>
{t('upgrade_now')}
</OLButton>
<hr className="thin my-3" />
<OLButton
variant="link"
className="btn-inline-link"
onClick={showContactModal}
>
{t('need_more_than_x_licenses', {
x: 50,
})}{' '}
{t('please_get_in_touch')}
</OLButton>
</div>
</OLModalFooter>
</OLModal>
</>
) )
} }

View file

@ -1,13 +1,19 @@
import { useState } from 'react' import { useState } from 'react'
import { Modal } from 'react-bootstrap'
import { useTranslation, Trans } from 'react-i18next' import { useTranslation, Trans } from 'react-i18next'
import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids' import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
import { postJSON } from '../../../../../../../../infrastructure/fetch-json' import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
import AccessibleModal from '../../../../../../../../shared/components/accessible-modal'
import getMeta from '../../../../../../../../utils/meta' import getMeta from '../../../../../../../../utils/meta'
import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context' import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
import { subscriptionUpdateUrl } from '../../../../../../data/subscription-url' import { subscriptionUpdateUrl } from '../../../../../../data/subscription-url'
import { useLocation } from '../../../../../../../../shared/hooks/use-location' import { useLocation } from '../../../../../../../../shared/hooks/use-location'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLNotification from '@/features/ui/components/ol/ol-notification'
export function ConfirmChangePlanModal() { export function ConfirmChangePlanModal() {
const modalId: SubscriptionDashModalIds = 'change-to-plan' const modalId: SubscriptionDashModalIds = 'change-to-plan'
@ -46,23 +52,29 @@ export function ConfirmChangePlanModal() {
planCodesChangingAtTermEnd.indexOf(planCodeToChangeTo) > -1 planCodesChangingAtTermEnd.indexOf(planCodeToChangeTo) > -1
return ( return (
<AccessibleModal <OLModal
id={modalId} id={modalId}
show show
animation animation
onHide={handleCloseModal} onHide={handleCloseModal}
backdrop="static" backdrop="static"
> >
<Modal.Header> <OLModalHeader>
<Modal.Title>{t('change_plan')}</Modal.Title> <OLModalTitle>{t('change_plan')}</OLModalTitle>
</Modal.Header> </OLModalHeader>
<Modal.Body> <OLModalBody>
{error && ( {error && (
<div className="alert alert-danger" aria-live="polite"> <OLNotification
{t('generic_something_went_wrong')}. {t('try_again')}.{' '} type="error"
{t('generic_if_problem_continues_contact_us')}. aria-live="polite"
</div> content={
<>
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
{t('generic_if_problem_continues_contact_us')}.
</>
}
/>
)} )}
<p> <p>
<Trans <Trans
@ -84,24 +96,30 @@ export function ConfirmChangePlanModal() {
<p>{t('want_change_to_apply_before_plan_end')}</p> <p>{t('want_change_to_apply_before_plan_end')}</p>
</> </>
)} )}
</Modal.Body> </OLModalBody>
<Modal.Footer> <OLModalFooter>
<button <OLButton
variant="secondary"
disabled={inflight} disabled={inflight}
className="btn btn-secondary"
onClick={handleCloseModal} onClick={handleCloseModal}
> >
{t('cancel')} {t('cancel')}
</button> </OLButton>
<button <OLButton
variant="primary"
disabled={inflight} disabled={inflight}
className="btn btn-primary" isLoading={inflight}
onClick={handleConfirmChange} onClick={handleConfirmChange}
bs3Props={{
loading: inflight
? t('processing_uppercase') + '…'
: t('change_plan'),
}}
> >
{!inflight ? t('change_plan') : t('processing_uppercase') + '…'} {t('change_plan')}
</button> </OLButton>
</Modal.Footer> </OLModalFooter>
</AccessibleModal> </OLModal>
) )
} }

View file

@ -1,12 +1,18 @@
import { useState } from 'react' import { useState } from 'react'
import { Modal } from 'react-bootstrap'
import { useTranslation, Trans } from 'react-i18next' import { useTranslation, Trans } from 'react-i18next'
import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids' import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
import { postJSON } from '../../../../../../../../infrastructure/fetch-json' import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
import AccessibleModal from '../../../../../../../../shared/components/accessible-modal'
import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context' import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
import { cancelPendingSubscriptionChangeUrl } from '../../../../../../data/subscription-url' import { cancelPendingSubscriptionChangeUrl } from '../../../../../../data/subscription-url'
import { useLocation } from '../../../../../../../../shared/hooks/use-location' import { useLocation } from '../../../../../../../../shared/hooks/use-location'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLNotification from '@/features/ui/components/ol/ol-notification'
export function KeepCurrentPlanModal() { export function KeepCurrentPlanModal() {
const modalId: SubscriptionDashModalIds = 'keep-current-plan' const modalId: SubscriptionDashModalIds = 'keep-current-plan'
@ -33,23 +39,29 @@ export function KeepCurrentPlanModal() {
if (modalIdShown !== modalId || !personalSubscription) return null if (modalIdShown !== modalId || !personalSubscription) return null
return ( return (
<AccessibleModal <OLModal
id={modalId} id={modalId}
show show
animation animation
onHide={handleCloseModal} onHide={handleCloseModal}
backdrop="static" backdrop="static"
> >
<Modal.Header> <OLModalHeader>
<Modal.Title>{t('change_plan')}</Modal.Title> <OLModalTitle>{t('change_plan')}</OLModalTitle>
</Modal.Header> </OLModalHeader>
<Modal.Body> <OLModalBody>
{error && ( {error && (
<div className="alert alert-danger" aria-live="polite"> <OLNotification
{t('generic_something_went_wrong')}. {t('try_again')}.{' '} type="error"
{t('generic_if_problem_continues_contact_us')}. aria-live="polite"
</div> content={
<>
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
{t('generic_if_problem_continues_contact_us')}.
</>
}
/>
)} )}
<p> <p>
<Trans <Trans
@ -65,26 +77,30 @@ export function KeepCurrentPlanModal() {
]} ]}
/> />
</p> </p>
</Modal.Body> </OLModalBody>
<Modal.Footer> <OLModalFooter>
<button <OLButton
variant="secondary"
disabled={inflight} disabled={inflight}
className="btn btn-secondary"
onClick={handleCloseModal} onClick={handleCloseModal}
> >
{t('cancel')} {t('cancel')}
</button> </OLButton>
<button <OLButton
variant="primary"
disabled={inflight} disabled={inflight}
className="btn btn-primary" isLoading={inflight}
onClick={confirmCancelPendingPlanChange} onClick={confirmCancelPendingPlanChange}
bs3Props={{
loading: inflight
? t('processing_uppercase') + '…'
: t('revert_pending_plan_change'),
}}
> >
{!inflight {t('revert_pending_plan_change')}
? t('revert_pending_plan_change') </OLButton>
: t('processing_uppercase') + '…'} </OLModalFooter>
</button> </OLModal>
</Modal.Footer>
</AccessibleModal>
) )
} }

View file

@ -10,6 +10,10 @@ import ManagedInstitutions from './managed-institutions'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context' import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import getMeta from '../../../../utils/meta' import getMeta from '../../../../utils/meta'
import PremiumFeaturesLink from './premium-features-link' import PremiumFeaturesLink from './premium-features-link'
import OLCard from '@/features/ui/components/ol/ol-card'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLNotification from '@/features/ui/components/ol/ol-notification'
function SubscriptionDashboard() { function SubscriptionDashboard() {
const { t } = useTranslation() const { t } = useTranslation()
@ -23,30 +27,35 @@ function SubscriptionDashboard() {
return ( return (
<div className="container"> <div className="container">
<div className="row"> <OLRow>
<div className="col-md-8 col-md-offset-2"> <OLCol lg={{ span: 8, offset: 2 }}>
{fromPlansPage && ( {fromPlansPage && (
<div className="alert alert-warning" aria-live="polite"> <OLNotification
{t('you_already_have_a_subscription')} className="mb-4"
</div> aria-live="polite"
content={t('you_already_have_a_subscription')}
type="warning"
/>
)} )}
<div className="card"> <OLCard>
<div className="page-header"> <div className="page-header">
<h1>{t('your_subscription')}</h1> <h1>{t('your_subscription')}</h1>
</div> </div>
<PersonalSubscription /> <div>
<ManagedGroupSubscriptions /> <PersonalSubscription />
<ManagedInstitutions /> <ManagedGroupSubscriptions />
<ManagedPublishers /> <ManagedInstitutions />
<GroupSubscriptionMemberships /> <ManagedPublishers />
<InstitutionMemberships /> <GroupSubscriptionMemberships />
{hasValidActiveSubscription && <PremiumFeaturesLink />} <InstitutionMemberships />
{!hasDisplayedSubscription && {hasValidActiveSubscription && <PremiumFeaturesLink />}
(hasSubscription ? <ContactSupport /> : <FreePlan />)} {!hasDisplayedSubscription &&
</div> (hasSubscription ? <ContactSupport /> : <FreePlan />)}
</div> </div>
</div> </OLCard>
</OLCol>
</OLRow>
</div> </div>
) )
} }

View file

@ -50,6 +50,8 @@ export default function OLModal({ children, ...props }: OLModalProps) {
backdrop: bs5Props.backdrop, backdrop: bs5Props.backdrop,
animation: bs5Props.animation, animation: bs5Props.animation,
id: bs5Props.id, id: bs5Props.id,
className: bs5Props.className,
backdropClassName: bs5Props.backdropClassName,
...bs3Props, ...bs3Props,
} }

View file

@ -1,5 +1,10 @@
import importOverleafModules from '../../../macros/import-overleaf-module.macro' import importOverleafModules from '../../../macros/import-overleaf-module.macro'
import { JSXElementConstructor, useCallback, useState } from 'react' import {
JSXElementConstructor,
useCallback,
useState,
type UIEvent,
} from 'react'
const [contactUsModalModules] = importOverleafModules('contactUsModal') const [contactUsModalModules] = importOverleafModules('contactUsModal')
const ContactUsModal: JSXElementConstructor<{ const ContactUsModal: JSXElementConstructor<{
@ -16,7 +21,7 @@ export const useContactUsModal = (options = { autofillProjectUrl: true }) => {
setShow(false) setShow(false)
}, []) }, [])
const showModal = useCallback((event?: Event) => { const showModal = useCallback((event?: Event | UIEvent) => {
event?.preventDefault() event?.preventDefault()
setShow(true) setShow(true)
}, []) }, [])

View file

@ -1,3 +1,12 @@
// Hack to make the contact modal appear above other modals
.contact-modal {
z-index: 1065;
}
.contact-backdrop {
z-index: 1060;
}
.contact-us-modal { .contact-us-modal {
textarea { textarea {
height: 120px; height: 120px;

View file

@ -1,3 +1,11 @@
#change-to-group .modal-header h4 {
line-height: 1.33rem;
.h5 {
font-family: @font-family-sans-serif;
}
}
.group-subscription-modal { .group-subscription-modal {
.modal-header { .modal-header {
text-align: center; text-align: center;

View file

@ -150,3 +150,7 @@
} }
} }
} }
.card-gray {
background-color: var(--neutral-10);
}

View file

@ -1,3 +1,12 @@
// Hack to make the contact modal appear above other modals
.contact-modal {
z-index: 1065;
}
.contact-backdrop {
z-index: 1060;
}
.contact-us-modal-textarea { .contact-us-modal-textarea {
height: 120px; height: 120px;
} }

View file

@ -11,5 +11,6 @@
@import 'editor/outline'; @import 'editor/outline';
@import 'editor/file-tree'; @import 'editor/file-tree';
@import 'editor/figure-modal'; @import 'editor/figure-modal';
@import 'subscription';
@import 'website-redesign'; @import 'website-redesign';
@import 'group-settings'; @import 'group-settings';

View file

@ -0,0 +1,140 @@
/**
* MAIN CONTENT
*/
#subscription-dashboard-root {
.hover-highlight {
&:hover,
&:focus {
background-color: var(--neutral-10);
}
}
li.row-link {
display: flex;
border: 0;
padding: 0;
a {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-02) 0;
text-decoration: none;
color: var(--neutral-90);
width: 100%;
&:hover {
background-color: var(--neutral-10);
}
}
}
}
/**
* MODALS
*/
.group-subscription-modal {
.circle {
font-size: var(--font-size-06);
border-radius: 50%;
background-color: var(--green-70);
color: white;
white-space: nowrap;
height: 180px;
width: 180px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: var(--spacing-02);
font-weight: bold;
.small {
opacity: 0.85;
}
.circle-subtext {
font-size: var(--font-size-03);
}
}
.legend-as-label {
font-size: var(--font-size-02);
font-weight: 600;
color: var(--content-secondary);
margin-bottom: 0;
}
.educational-discount-badge {
height: 50px;
p {
display: inline-block;
font-weight: bold;
padding-left: var(--spacing-02);
padding-right: var(--spacing-02);
}
.applied {
background-color: rgba($green-70, 0.1);
color: var(--green-70);
}
.ineligible {
background-color: var(--neutral-10);
}
}
}
#change-plan {
table {
@include media-breakpoint-up(lg) {
th:last-child,
td:last-child {
width: 1%; // will expand to fit the content
}
}
@include media-breakpoint-down(lg) {
display: block;
thead {
display: none;
}
tbody,
td,
tr {
display: inline-block;
padding-top: 0;
padding-bottom: 0;
text-align: center;
width: 100%;
}
td:first-child {
padding-top: var(--spacing-07);
}
td:last-child {
padding-top: var(--spacing-03);
padding-bottom: var(--spacing-07);
}
tr {
border-bottom: 1px solid var(--bs-border-color);
td,
th {
border: 0 !important;
}
}
tr:last-child {
border-bottom: 0;
}
}
}
}

View file

@ -42,6 +42,8 @@ label {
font-size: @font-size-base; font-size: @font-size-base;
color: @text-color; color: @text-color;
border: 0; border: 0;
margin-bottom: 0;
line-height: @line-height-01;
} }
// Normalize form controls // Normalize form controls

View file

@ -48,7 +48,3 @@
} }
} }
} }
.list-item-with-margin-bottom {
margin-bottom: @line-height-computed;
}

View file

@ -30,6 +30,7 @@ export function renderWithSubscriptionDashContext(
options?.metaTags?.forEach(tag => options?.metaTags?.forEach(tag =>
window.metaAttributesCache.set(tag.name, tag.value) window.metaAttributesCache.set(tag.name, tag.value)
) )
window.metaAttributesCache.set('ol-user', {})
if (!options?.recurlyNotLoaded) { if (!options?.recurlyNotLoaded) {
// @ts-ignore // @ts-ignore