[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-plans", data-type="json" content=plans)
meta(name="ol-groupSettingsEnabledFor", data-type="json" content=groupSettingsEnabledFor)
meta(name="ol-user" data-type="json" content=user)
if (personalSubscription && personalSubscription.recurly)
meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey)
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 OLNotification from '@/features/ui/components/ol/ol-notification'
export default function GenericErrorAlert({
className,
@ -7,12 +7,18 @@ export default function GenericErrorAlert({
className?: string
}) {
const { t } = useTranslation()
const alertClassName = classNames('alert', 'alert-danger', className)
return (
<div className={alertClassName} aria-live="polite">
<OLNotification
className={className}
aria-live="polite"
type="error"
content={
<>
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
{t('generic_if_problem_continues_contact_us')}.
</div>
</>
}
/>
)
}

View file

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

View file

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

View file

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

View file

@ -100,6 +100,7 @@ export default function ManagedGroupSubscriptions() {
<p>
<ManagedGroupAdministrator subscription={subscription} />
</p>
<ul className="list-group p-0">
<RowLink
href={`/manage/groups/${subscription._id}/members`}
heading={t('manage_members')}
@ -121,6 +122,7 @@ export default function ManagedGroupSubscriptions() {
subtext={t('view_metrics_group_subtext')}
icon="insights"
/>
</ul>
<hr />
</div>
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import MaterialIcon from '../../../../shared/components/material-icon'
import { isBootstrap5 } from '@/features/utils/bootstrap-5'
type RowLinkProps = {
href: string
@ -7,7 +8,11 @@ type RowLinkProps = {
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 (
<a href={href} className="row-link">
<div className="icon">
@ -23,3 +28,18 @@ export function RowLink({ href, heading, subtext, icon }: RowLinkProps) {
</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 { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
export function ActiveSubscription({
subscription,
@ -62,12 +63,13 @@ export function ActiveSubscription({
subscription.recurly.account.has_past_due_invoice._ !== 'true' && (
<>
{' '}
<button
<OLButton
variant="link"
className="btn-inline-link"
onClick={() => setModalIdShown('change-plan')}
>
{t('change_plan')}
</button>
</OLButton>
</>
)}
</p>
@ -103,7 +105,7 @@ export function ActiveSubscription({
/>
</p>
<PriceExceptions subscription={subscription} />
<p>
<p className="d-inline-flex flex-wrap gap-1">
<a
href={subscription.recurly.billingDetailsLink}
target="_blank"
@ -121,7 +123,10 @@ export function ActiveSubscription({
{t('view_your_invoices')}
</a>
{!recurlyLoadError && (
<CancelSubscriptionButton className="btn btn-danger-ghost ms-1" />
<>
{' '}
<CancelSubscriptionButton />
</>
)}
</p>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next'
import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
import OLButton from '@/features/ui/components/ol/ol-button'
export function ChangeToGroupPlan() {
const { t } = useTranslation()
@ -10,13 +11,13 @@ export function ChangeToGroupPlan() {
}
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>
<p style={{ margin: 0 }}>{t('reduce_costs_group_licenses')}</p>
<br />
<button className="btn btn-primary" onClick={handleClick}>
<OLButton variant="primary" onClick={handleClick}>
{t('change_to_group_plan')}
</button>
</OLButton>
</div>
)
}

View file

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

View file

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

View file

@ -1,16 +1,29 @@
import { useEffect, useState } from 'react'
import { Modal } from 'react-bootstrap'
import { useTranslation, Trans } from 'react-i18next'
import { Subscription } from '../../../../../../../../../../types/subscription/dashboard/subscription'
import { PriceForDisplayData } from '../../../../../../../../../../types/subscription/plan'
import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
import AccessibleModal from '../../../../../../../../shared/components/accessible-modal'
import getMeta from '../../../../../../../../utils/meta'
import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
import GenericErrorAlert from '../../../../generic-error-alert'
import { subscriptionUpdateUrl } from '../../../../../../data/subscription-url'
import { getRecurlyGroupPlanCode } from '../../../../../../util/recurly-group-plan-code'
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 groupSizeForEducationalDiscount = 10
@ -88,7 +101,7 @@ function GroupPrice({
})}
</span>
<br />
<BootstrapVersionSwitcher bs3={<br />} />
<span className="circle-subtext">
<span aria-hidden>
@ -124,6 +137,8 @@ export function ChangeToGroupModal() {
setGroupPlanToChangeToSize,
setGroupPlanToChangeToUsage,
} = useSubscriptionDashboardContext()
const { modal: contactModal, showModal: showContactModal } =
useContactUsModal({ autofillProjectUrl: false })
const groupPlans = getMeta('ol-groupPlans')
const personalSubscription = getMeta('ol-subscription') as Subscription
const [error, setError] = useState(false)
@ -157,11 +172,6 @@ export function ChangeToGroupModal() {
}
}, [personalSubscription, setGroupPlanToChangeToCode])
function handleGetInTouchButton() {
handleCloseModal()
$('[data-ol-contact-form-modal="contact-us"]').modal()
}
if (
modalIdShown !== modalId ||
!groupPlans ||
@ -172,34 +182,33 @@ export function ChangeToGroupModal() {
return null
return (
<AccessibleModal
<>
<UserProvider>{contactModal}</UserProvider>
<OLModal
id={modalId}
show
animation
onHide={handleCloseModal}
backdrop="static"
>
<Modal.Header>
<button className="close" onClick={handleCloseModal}>
<span aria-hidden="true">×</span>
<span className="sr-only">{t('close')}</span>
</button>
<div className="modal-title">
<h2>{t('customize_your_group_subscription')}</h2>
<h3>
<OLModalHeader closeButton>
<OLModalTitle className="lh-sm">
{t('customize_your_group_subscription')}
<br />
<span className="h5">
{t('save_x_percent_or_more', {
percent: '30',
})}
</h3>
</div>
</Modal.Header>
</span>
</OLModalTitle>
</OLModalHeader>
<Modal.Body>
<OLModalBody>
<div className="container-fluid plans group-subscription-modal">
{groupPlanToChangeToPriceError && <GenericErrorAlert />}
<div className="row">
<div className="col-md-6 text-center">
<div className="circle circle-lg">
<div className="circle circle-lg mb-4 mx-auto">
<GroupPrice
groupPlanToChangeToPrice={groupPlanToChangeToPrice}
queryingGroupPlanToChangeToPrice={
@ -209,7 +218,7 @@ export function ChangeToGroupModal() {
</div>
<p>{t('each_user_will_have_access_to')}:</p>
<ul className="list-unstyled">
<li className="list-item-with-margin-bottom">
<li className="mb-3">
<strong>
<GroupPlanCollaboratorCount
planCode={groupPlanToChangeToCode}
@ -234,53 +243,42 @@ export function ChangeToGroupModal() {
<fieldset className="form-group">
<legend className="legend-as-label">{t('plan')}</legend>
{groupPlans.plans.map(option => (
<label
htmlFor={`plan-option-${option.code}`}
<OLFormCheckbox
key={option.code}
className="group-plan-option"
>
<input
type="radio"
name="plan-code"
value={option.code}
id={`plan-option-${option.code}`}
onChange={e =>
setGroupPlanToChangeToCode(e.target.value)
}
onChange={() => setGroupPlanToChangeToCode(option.code)}
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
<OLFormGroup controlId="size">
<OLFormLabel>{t('number_of_users')}</OLFormLabel>
<OLFormSelect
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>
</div>
</OLFormSelect>
</OLFormGroup>
<div className="form-group">
<OLFormGroup>
<strong>
{t('percent_discount_for_groups', {
percent: educationalPercentDiscount,
size: groupSizeForEducationalDiscount,
})}
</strong>
</div>
</OLFormGroup>
<div className="form-group group-plan-option">
<label htmlFor="usage">
<input
<OLFormCheckbox
id="usage"
type="checkbox"
autoComplete="off"
@ -292,16 +290,12 @@ export function ChangeToGroupModal() {
setGroupPlanToChangeToUsage('enterprise')
}
}}
label={t('license_for_educational_purposes')}
/>
<span>{t('license_for_educational_purposes')}</span>
</label>
</div>
</form>
</div>
</div>
<div className="row">
<div className="col-md-12 text-center">
<div className="educational-discount-badge">
<div className="educational-discount-badge pt-4 text-center">
{groupPlanToChangeToUsage === 'educational' && (
<EducationDiscountAppliedOrNot
groupSize={groupPlanToChangeToSize}
@ -309,11 +303,9 @@ export function ChangeToGroupModal() {
)}
</div>
</div>
</div>
</div>
</Modal.Body>
</OLModalBody>
<Modal.Footer>
<OLModalFooter>
<div className="text-center">
{groupPlanToChangeToPrice?.includesTax && (
<p>
@ -334,35 +326,55 @@ export function ChangeToGroupModal() {
</p>
)}
<p>
<strong>{t('new_subscription_will_be_billed_immediately')}</strong>
<strong>
{t('new_subscription_will_be_billed_immediately')}
</strong>
</p>
<hr className="thin" />
<hr className="thin my-3" />
{error && (
<div className="alert alert-danger" aria-live="polite">
<OLNotification
type="error"
aria-live="polite"
content={
<>
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
{t('generic_if_problem_continues_contact_us')}.
</div>
</>
}
/>
)}
<button
className="btn btn-primary btn-lg"
<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}
>
{!inflight ? t('upgrade_now') : t('processing_uppercase') + '…'}
</button>
<hr className="thin" />
<button className="btn-inline-link" onClick={handleGetInTouchButton}>
{t('need_more_than_x_licenses', {
x: 50,
})}{' '}
{t('please_get_in_touch')}
</button>
</OLButton>
</div>
</Modal.Footer>
</AccessibleModal>
</OLModalFooter>
</OLModal>
</>
)
}

View file

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

View file

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

View file

@ -10,6 +10,10 @@ import ManagedInstitutions from './managed-institutions'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import getMeta from '../../../../utils/meta'
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() {
const { t } = useTranslation()
@ -23,18 +27,22 @@ function SubscriptionDashboard() {
return (
<div className="container">
<div className="row">
<div className="col-md-8 col-md-offset-2">
<OLRow>
<OLCol lg={{ span: 8, offset: 2 }}>
{fromPlansPage && (
<div className="alert alert-warning" aria-live="polite">
{t('you_already_have_a_subscription')}
</div>
<OLNotification
className="mb-4"
aria-live="polite"
content={t('you_already_have_a_subscription')}
type="warning"
/>
)}
<div className="card">
<OLCard>
<div className="page-header">
<h1>{t('your_subscription')}</h1>
</div>
<div>
<PersonalSubscription />
<ManagedGroupSubscriptions />
<ManagedInstitutions />
@ -45,8 +53,9 @@ function SubscriptionDashboard() {
{!hasDisplayedSubscription &&
(hasSubscription ? <ContactSupport /> : <FreePlan />)}
</div>
</div>
</div>
</OLCard>
</OLCol>
</OLRow>
</div>
)
}

View file

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

View file

@ -1,5 +1,10 @@
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 ContactUsModal: JSXElementConstructor<{
@ -16,7 +21,7 @@ export const useContactUsModal = (options = { autofillProjectUrl: true }) => {
setShow(false)
}, [])
const showModal = useCallback((event?: Event) => {
const showModal = useCallback((event?: Event | UIEvent) => {
event?.preventDefault()
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 {
textarea {
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 {
.modal-header {
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 {
height: 120px;
}

View file

@ -11,5 +11,6 @@
@import 'editor/outline';
@import 'editor/file-tree';
@import 'editor/figure-modal';
@import 'subscription';
@import 'website-redesign';
@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;
color: @text-color;
border: 0;
margin-bottom: 0;
line-height: @line-height-01;
}
// 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 =>
window.metaAttributesCache.set(tag.name, tag.value)
)
window.metaAttributesCache.set('ol-user', {})
if (!options?.recurlyNotLoaded) {
// @ts-ignore