first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
import { useTranslation } from 'react-i18next'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLPageContentCard from '@/features/ui/components/ol/ol-page-content-card'
import OLNotification from '@/features/ui/components/ol/ol-notification'
function Canceled() {
const { t } = useTranslation()
return (
<div className="container">
<OLRow>
<OLCol lg={{ span: 8, offset: 2 }}>
<OLPageContentCard>
<div className="page-header">
<h2>{t('subscription_canceled')}</h2>
</div>
<OLNotification
type="info"
content={
<p>
{t('to_modify_your_subscription_go_to')}&nbsp;
<a href="/user/subscription" rel="noopener noreferrer">
{t('manage_subscription')}.
</a>
</p>
}
/>
<p>
<a
className="btn btn-primary"
href="/project"
rel="noopener noreferrer"
>
&lt; {t('back_to_your_projects')}
</a>
</p>
</OLPageContentCard>
</OLCol>
</OLRow>
</div>
)
}
export default Canceled

View File

@@ -0,0 +1,14 @@
import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n'
import CanceledSubscription from './canceled'
function Root() {
const { isReady } = useWaitForI18n()
if (!isReady) {
return null
}
return <CanceledSubscription />
}
export default Root

View File

@@ -0,0 +1,12 @@
import { Trans } from 'react-i18next'
export default function ContactSupport() {
return (
<p>
<Trans
i18nKey="you_are_on_a_paid_plan_contact_support_to_find_out_more"
components={[<a href="/contact" />]} // eslint-disable-line react/jsx-key, jsx-a11y/anchor-has-content
/>
</p>
)
}

View File

@@ -0,0 +1,33 @@
import { useTranslation, Trans } from 'react-i18next'
function FreePlan() {
const { t } = useTranslation()
return (
<>
<Trans
i18nKey="on_free_plan_upgrade_to_access_features"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a href="/learn/how-to/Overleaf_premium_features" target="_blank" />,
]}
/>
:
<ul>
<li>{t('invite_more_collabs')}</li>
<li>{t('realtime_track_changes')}</li>
<li>{t('full_doc_history')}</li>
<li>{t('reference_search')}</li>
<li>{t('reference_sync')}</li>
<li>{t('dropbox_integration_lowercase')}</li>
<li>{t('github_integration_lowercase')}</li>
<li>{t('priority_support')}</li>
</ul>
<a className="btn btn-primary me-1" href="/user/subscription/plans">
{t('upgrade_now')}
</a>
</>
)
}
export default FreePlan

View File

@@ -0,0 +1,24 @@
import { useTranslation } from 'react-i18next'
import OLNotification from '@/features/ui/components/ol/ol-notification'
export default function GenericErrorAlert({
className,
}: {
className?: string
}) {
const { t } = useTranslation()
return (
<OLNotification
className={className}
aria-live="polite"
type="error"
content={
<>
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
{t('generic_if_problem_continues_contact_us')}.
</>
}
/>
)
}

View File

@@ -0,0 +1,96 @@
import { RowLink } from '@/features/subscription/components/dashboard/row-link'
import { useTranslation } from 'react-i18next'
import { useLocation } from '@/shared/hooks/use-location'
import MaterialIcon from '@/shared/components/material-icon'
import OLTag from '@/features/ui/components/ol/ol-tag'
import { ManagedGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription'
import { sendMB } from '../../../../infrastructure/event-tracking'
import starIcon from '../../images/star-gradient.svg'
function AvailableWithGroupProfessionalBadge() {
const { t } = useTranslation()
const location = useLocation()
const handleUpgradeClick = () => {
sendMB('flex-upgrade', {
location: 'ad-badge',
})
location.assign('/user/subscription/group/upgrade-subscription')
}
return (
<OLTag
prepend={<img aria-hidden="true" src={starIcon} alt="" />}
contentProps={{
className: 'mw-100',
onClick: handleUpgradeClick,
}}
>
<strong>{t('available_with_group_professional')}</strong>
</OLTag>
)
}
function useGroupSettingsButton(subscription: ManagedGroupSubscription) {
const { t } = useTranslation()
const subscriptionHasManagedUsers =
subscription.features?.managedUsers === true
const subscriptionHasGroupSSO = subscription.features?.groupSSO === true
const heading = t('group_settings')
let groupSettingRowSubText = ''
if (subscriptionHasGroupSSO && subscriptionHasManagedUsers) {
groupSettingRowSubText = t('manage_group_settings_subtext')
} else if (subscriptionHasGroupSSO) {
groupSettingRowSubText = t('manage_group_settings_subtext_group_sso')
} else if (subscriptionHasManagedUsers) {
groupSettingRowSubText = t('manage_group_settings_subtext_managed_users')
}
return {
heading,
groupSettingRowSubText,
}
}
export function GroupSettingsButton({
subscription,
}: {
subscription: ManagedGroupSubscription
}) {
const { heading, groupSettingRowSubText } =
useGroupSettingsButton(subscription)
return (
<RowLink
href={`/manage/groups/${subscription._id}/settings`}
heading={heading}
subtext={groupSettingRowSubText}
icon="settings"
/>
)
}
export function GroupSettingsButtonWithAdBadge({
subscription,
}: {
subscription: ManagedGroupSubscription
}) {
const { heading, groupSettingRowSubText } =
useGroupSettingsButton(subscription)
return (
<li className="list-group-item row-link">
<div className="row-link-inner">
<MaterialIcon type="settings" className="p-2 p-md-3 text-muted" />
<div className="flex-grow-1 text-truncate text-muted">
<strong>{heading}</strong>
<div className="text-truncate">{groupSettingRowSubText}</div>
</div>
<span className="p-2 p-md-3">
<AvailableWithGroupProfessionalBadge />
</span>
</div>
</li>
)
}

View File

@@ -0,0 +1,63 @@
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
}
export default function GroupSubscriptionMembership({
subscription,
}: GroupSubscriptionMembershipProps) {
const { t } = useTranslation()
const { handleOpenModal, setLeavingGroupId } =
useSubscriptionDashboardContext()
const leaveGroup = () => {
handleOpenModal(LEAVE_GROUP_MODAL_ID)
setLeavingGroupId(subscription._id)
}
// Hide leave group button for managed users
const hideLeaveButton = getMeta('ol-cannot-leave-group-subscription')
return (
<div>
<p>
<Trans
i18nKey="you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z"
components={[<a href="/user/subscription/plans" />, <strong />]} // eslint-disable-line react/jsx-key, jsx-a11y/anchor-has-content
values={{
planName: subscription.planLevelName,
groupName: subscription.teamName || '',
adminEmail: subscription.admin_id.email,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
{subscription.teamNotice && (
<p>
{/* Team notice is sanitized in SubscriptionViewModelBuilder */}
<em>{subscription.teamNotice}</em>
</p>
)}
{hideLeaveButton ? (
<span>
{' '}
{t('need_to_leave')} {t('contact_group_admin')}{' '}
</span>
) : (
<span>
<OLButton variant="danger" onClick={leaveGroup}>
{t('leave_group')}
</OLButton>
</span>
)}
<hr />
</div>
)
}

View File

@@ -0,0 +1,31 @@
import { MemberGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import GroupSubscriptionMembership from './group-subscription-membership'
import LeaveGroupModal from './leave-group-modal'
export default function GroupSubscriptionMemberships() {
const { memberGroupSubscriptions } = useSubscriptionDashboardContext()
if (!memberGroupSubscriptions) {
return null
}
const memberOnlyGroupSubscriptions = memberGroupSubscriptions.filter(
subscription => !subscription.userIsGroupManager
)
return (
<>
{memberOnlyGroupSubscriptions.map(
(subscription: MemberGroupSubscription) => (
<GroupSubscriptionMembership
subscription={subscription}
key={subscription._id}
/>
)
)}
<LeaveGroupModal />
</>
)
}

View File

@@ -0,0 +1,56 @@
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()
// memberships is undefined when data failed to load. If user has no memberships, then an empty array is returned
if (!institutionMemberships) {
return (
<OLNotification
type="warning"
content={
<p>
Sorry, something went wrong. Subscription information related to
institutional affiliations may not be displayed. Please try again
later.
</p>
}
/>
)
}
if (!institutionMemberships.length) return null
return (
<div>
{institutionMemberships.map((institution: Institution) => (
<div key={`${institution.id}`}>
<Trans
i18nKey="you_are_on_x_plan_as_a_confirmed_member_of_institution_y"
values={{
planName: 'Professional',
institutionName: institution.name || '',
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a href="/user/subscription/plans" rel="noopener" />,
// eslint-disable-next-line react/jsx-key
<strong />,
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
<hr />
</div>
))}
</div>
)
}
export default InstitutionMemberships

View File

@@ -0,0 +1,80 @@
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { deleteJSON } from '../../../../infrastructure/fetch-json'
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'
export default function LeaveGroupModal() {
const { t } = useTranslation()
const { handleCloseModal, modalIdShown, leavingGroupId } =
useSubscriptionDashboardContext()
const [inflight, setInflight] = useState(false)
const location = useLocation()
const handleConfirmLeaveGroup = useCallback(async () => {
if (!leavingGroupId) {
return
}
setInflight(true)
try {
const params = new URLSearchParams()
params.set('subscriptionId', leavingGroupId)
await deleteJSON(`/subscription/group/user?${params}`)
location.reload()
} catch (error) {
debugConsole.error('something went wrong', error)
setInflight(false)
}
}, [location, leavingGroupId])
if (modalIdShown !== LEAVE_GROUP_MODAL_ID || !leavingGroupId) {
return null
}
return (
<OLModal
id={LEAVE_GROUP_MODAL_ID}
show
animation
onHide={handleCloseModal}
backdrop="static"
>
<OLModalHeader>
<OLModalTitle>{t('leave_group')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>{t('sure_you_want_to_leave_group')}</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={handleCloseModal}
disabled={inflight}
>
{t('cancel')}
</OLButton>
<OLButton
variant="danger"
onClick={handleConfirmLeaveGroup}
disabled={inflight}
isLoading={inflight}
loadingLabel={t('processing_uppercase') + '…'}
>
{t('leave_now')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,141 @@
import {
GroupSettingsButton,
GroupSettingsButtonWithAdBadge,
} from '@/features/subscription/components/dashboard/group-settings-button'
import getMeta from '@/utils/meta'
import { Trans, useTranslation } from 'react-i18next'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import { RowLink } from './row-link'
import { ManagedGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription'
function ManagedGroupAdministrator({
subscription,
}: {
subscription: ManagedGroupSubscription
}) {
const usersEmail = getMeta('ol-usersEmail')
const values = {
planName: subscription.planLevelName,
groupName: subscription.teamName || '',
adminEmail: subscription.admin_id.email,
}
const isAdmin = usersEmail === subscription.admin_id.email
if (subscription.userIsGroupMember && !isAdmin) {
return (
<Trans
i18nKey="you_are_a_manager_and_member_of_x_plan_as_member_of_group_subscription_y_administered_by_z"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a href="/user/subscription/plans" />,
// eslint-disable-next-line react/jsx-key
<strong />,
]}
values={values}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
)
} else if (subscription.userIsGroupMember && isAdmin) {
return (
<Trans
i18nKey="you_are_a_manager_and_member_of_x_plan_as_member_of_group_subscription_y_administered_by_z_you"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a href="/user/subscription/plans" />,
// eslint-disable-next-line react/jsx-key
<strong />,
]}
values={values}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
)
} else if (isAdmin) {
return (
<Trans
i18nKey="you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z_you"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a href="/user/subscription/plans" />,
// eslint-disable-next-line react/jsx-key
<strong />,
]}
values={values}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
)
}
return (
<Trans
i18nKey="you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a href="/user/subscription/plans" />,
// eslint-disable-next-line react/jsx-key
<strong />,
]}
values={values}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
)
}
export default function ManagedGroupSubscriptions() {
const { t } = useTranslation()
const { managedGroupSubscriptions } = useSubscriptionDashboardContext()
if (!managedGroupSubscriptions) {
return null
}
const groupSettingsAdvertisedFor =
getMeta('ol-groupSettingsAdvertisedFor') || []
const groupSettingsEnabledFor = getMeta('ol-groupSettingsEnabledFor') || []
return (
<>
{managedGroupSubscriptions.map(subscription => {
return (
<div key={`managed-group-${subscription._id}`}>
<h2 className="h3 fw-bold">{t('group_management')}</h2>
<p>
<ManagedGroupAdministrator subscription={subscription} />
</p>
<ul className="list-group p-0">
<RowLink
href={`/manage/groups/${subscription._id}/members`}
heading={t('group_members')}
subtext={t('manage_group_members_subtext')}
icon="groups"
/>
<RowLink
href={`/manage/groups/${subscription._id}/managers`}
heading={t('group_managers')}
subtext={t('manage_managers_subtext')}
icon="manage_accounts"
/>
{groupSettingsEnabledFor?.includes(subscription._id) && (
<GroupSettingsButton subscription={subscription} />
)}
{groupSettingsAdvertisedFor?.includes(subscription._id) && (
<GroupSettingsButtonWithAdBadge subscription={subscription} />
)}
<RowLink
href={`/metrics/groups/${subscription._id}`}
heading={t('usage_metrics')}
subtext={t('view_metrics_group_subtext')}
icon="insights"
/>
</ul>
<hr />
</div>
)
})}
</>
)
}

View File

@@ -0,0 +1,102 @@
import { useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { postJSON } from '../../../../infrastructure/fetch-json'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import { ManagedInstitution as Institution } from '../../../../../../types/subscription/dashboard/managed-institution'
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
}
export default function ManagedInstitution({
institution,
}: ManagedInstitutionProps) {
const { t } = useTranslation()
const [subscriptionChanging, setSubscriptionChanging] = useState(false)
const { updateManagedInstitution } = useSubscriptionDashboardContext()
const changeInstitutionalEmailSubscription = useCallback(
(e, institutionId: Institution['v1Id']) => {
const updateSubscription = async (institutionId: Institution['v1Id']) => {
setSubscriptionChanging(true)
try {
const data = await postJSON<string[]>(
`/institutions/${institutionId}/emailSubscription`
)
institution.metricsEmail.optedOutUserIds = data
updateManagedInstitution(institution)
} catch (error) {
debugConsole.error(error)
}
setSubscriptionChanging(false)
}
e.preventDefault()
updateSubscription(institutionId)
},
[institution, updateManagedInstitution]
)
return (
<div>
<p>
<Trans
i18nKey="you_are_a_manager_of_commons_at_institution_x"
components={[<strong />]} // eslint-disable-line react/jsx-key
values={{
institutionName: institution.name || '',
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
<ul className="list-group p-0">
<RowLink
href={`/metrics/institutions/${institution.v1Id}`}
heading={t('view_metrics')}
subtext={t('view_metrics_commons_subtext')}
icon="insights"
/>
<RowLink
href={`/institutions/${institution.v1Id}/hub`}
heading={t('view_hub')}
subtext={t('view_hub_subtext')}
icon="account_circle"
/>
<RowLink
href={`/manage/institutions/${institution.v1Id}/managers`}
heading={t('manage_institution_managers')}
subtext={t('manage_managers_subtext')}
icon="manage_accounts"
/>
</ul>
<div>
<p>
<span>Monthly metrics emails: </span>
{subscriptionChanging ? (
<i className="fa fa-spin fa-refresh" />
) : (
<OLButton
variant="link"
className="btn-inline-link"
onClick={e =>
changeInstitutionalEmailSubscription(e, institution.v1Id)
}
>
{institution.metricsEmail.optedOutUserIds.includes(
getMeta('ol-user_id')!
)
? t('subscribe')
: t('unsubscribe')}
</OLButton>
)}
</p>
</div>
<hr />
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import ManagedInstitution from './managed-institution'
export default function ManagedInstitutions() {
const { managedInstitutions } = useSubscriptionDashboardContext()
if (!managedInstitutions) {
return null
}
return (
<>
{managedInstitutions.map(institution => (
<ManagedInstitution
institution={institution}
key={`managed-institution-${institution.v1Id}`}
/>
))}
</>
)
}

View File

@@ -0,0 +1,42 @@
import { Trans, useTranslation } from 'react-i18next'
import { RowLink } from './row-link'
import { Publisher } from '../../../../../../types/subscription/dashboard/publisher'
type ManagedPublisherProps = {
publisher: Publisher
}
export default function ManagedPublisher({ publisher }: ManagedPublisherProps) {
const { t } = useTranslation()
return (
<div>
<p>
<Trans
i18nKey="you_are_a_manager_of_publisher_x"
components={[<strong />]} // eslint-disable-line react/jsx-key
values={{
publisherName: publisher.name || '',
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
<ul className="list-group p-0">
<RowLink
href={`/publishers/${publisher.slug}/hub`}
heading={t('view_hub')}
subtext={t('view_hub_subtext')}
icon="account_circle"
/>
<RowLink
href={`/manage/publishers/${publisher.slug}/managers`}
heading={t('manage_publisher_managers')}
subtext={t('manage_managers_subtext')}
icon="manage_accounts"
/>
</ul>
<hr />
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import ManagedPublisher from './managed-publisher'
export default function ManagedPublishers() {
const { managedPublishers } = useSubscriptionDashboardContext()
if (!managedPublishers) {
return null
}
return (
<>
{managedPublishers.map(publisher => (
<ManagedPublisher
publisher={publisher}
key={`managed-publisher-${publisher.slug}`}
/>
))}
</>
)
}

View File

@@ -0,0 +1,138 @@
import { useTranslation } from 'react-i18next'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import { useCallback, useMemo, useState } from 'react'
import { postJSON } from '@/infrastructure/fetch-json'
import { useLocation } from '@/shared/hooks/use-location'
import OLModal, {
OLModalBody,
OLModalHeader,
} from '@/features/ui/components/ol/ol-modal'
import { Select } from '@/shared/components/select'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import Button from '@/features/ui/components/bootstrap-5/button'
import { Stack } from 'react-bootstrap-5'
import { debugConsole } from '@/utils/debugging'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import PauseDuck from '../../images/pause-duck.svg'
import GenericErrorAlert from './generic-error-alert'
import { PaidSubscription } from '../../../../../../types/subscription/dashboard/subscription'
const pauseMonthDurationOptions = [1, 2, 3]
export const PAUSE_SUB_MODAL_ID = 'pause-subscription'
export default function PauseSubscriptionModal() {
const { t } = useTranslation()
const {
handleCloseModal,
modalIdShown,
setShowCancellation,
personalSubscription,
} = useSubscriptionDashboardContext()
const [inflight, setInflight] = useState(false)
const [pauseError, setPauseError] = useState(false)
const [selectedDuration, setSelectedDuration] = useState(1)
const location = useLocation()
function handleCancelSubscriptionClick() {
const subscription = personalSubscription as PaidSubscription
eventTracking.sendMB('subscription-page-cancel-button-click', {
plan_code: subscription?.planCode,
is_trial:
subscription?.payment.trialEndsAtFormatted &&
subscription?.payment.trialEndsAt &&
new Date(subscription.payment.trialEndsAt).getTime() > Date.now(),
})
setShowCancellation(true)
}
const pauseSelectItems = useMemo(
() =>
pauseMonthDurationOptions.map(month => ({
key: month,
value: `${month} ${t('month', { count: month })}`,
})),
[t]
)
const handleConfirmPauseSubscriptionClick = useCallback(async () => {
if (!selectedDuration) {
return
}
setPauseError(false)
setInflight(true)
try {
await postJSON(`/user/subscription/pause/${selectedDuration}`)
const newUrl = new URL(location.toString())
newUrl.searchParams.set('flash', 'paused')
window.history.replaceState(null, '', newUrl)
location.reload()
} catch (err) {
debugConsole.error('error pausing subscription', err)
setInflight(false)
setPauseError(true)
}
}, [location, selectedDuration])
if (modalIdShown !== PAUSE_SUB_MODAL_ID) {
return null
}
return (
<OLModal
id={PAUSE_SUB_MODAL_ID}
show
animation
onHide={handleCloseModal}
backdrop="static"
>
<OLModalBody>
<OLModalHeader closeButton style={{ border: 0 }} />
<img
src={PauseDuck}
alt="Need to duck out for a while?"
style={{ display: 'block', margin: '-32px auto 0 auto' }}
/>
{pauseError && <GenericErrorAlert />}
<h4>{t('why_not_pause_instead')}</h4>
<p>{t('your_current_plan_gives_you')}</p>
<span>{t('dont_forget_you_currently_have')}</span>
<ul>
{personalSubscription?.plan?.features?.collaborators !== 1 && (
<li>{t('more_collabs_per_project')}</li>
)}
<li>{t('more_compile_time')}</li>
<li>{t('features_like_track_changes')}</li>
<li>{t('integrations_like_github')}</li>
</ul>
<OLFormGroup>
<Select
label={t('pause_subscription_for')}
items={pauseSelectItems}
itemToString={x => String(x?.value)}
itemToKey={x => String(x.key)}
defaultText={`1 ${t('month')}`}
onSelectedItemChanged={item => setSelectedDuration(item?.key || 0)}
/>
</OLFormGroup>
<Stack gap={2}>
<Button
onClick={handleConfirmPauseSubscriptionClick}
disabled={inflight}
>
{t('pause_subscription')}
</Button>
<Button
onClick={handleCancelSubscriptionClick}
disabled={inflight}
variant="danger-ghost"
>
{t('cancel_subscription')}
</Button>
</Stack>
</OLModalBody>
</OLModal>
)
}

View File

@@ -0,0 +1,67 @@
import { useTranslation, Trans } from 'react-i18next'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
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()
const { personalSubscription } = useSubscriptionDashboardContext()
const userEmail = getMeta('ol-usersEmail')
const { isLoading, isSuccess, runAsync } = useAsync()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
runAsync(postJSON('/user/subscription/account/email'))
}
if (!personalSubscription || !('payment' in personalSubscription)) return null
const recurlyEmail = personalSubscription.payment.accountEmail
if (!userEmail || recurlyEmail === userEmail) return null
return (
<>
<form onSubmit={handleSubmit}>
<OLFormGroup>
{isSuccess ? (
<OLNotification
type="success"
content={t('recurly_email_updated')}
/>
) : (
<>
<p>
<Trans
i18nKey="recurly_email_update_needed"
components={[<em />, <em />]} // eslint-disable-line react/jsx-key
values={{ recurlyEmail, userEmail }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
<div>
<OLButton
variant="primary"
type="submit"
disabled={isLoading}
isLoading={isLoading}
loadingLabel={t('updating')}
>
{t('update')}
</OLButton>
</div>
</>
)}
</OLFormGroup>
</form>
<hr />
</>
)
}
export default PersonalSubscriptionRecurlySyncEmail

View File

@@ -0,0 +1,118 @@
import { Trans, useTranslation } from 'react-i18next'
import { PaidSubscription } from '../../../../../../types/subscription/dashboard/subscription'
import { PausedSubscription } from './states/active/paused'
import { ActiveSubscriptionNew } from '@/features/subscription/components/dashboard/states/active/active-new'
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,
}: {
subscription: PaidSubscription
}) {
const { t } = useTranslation()
return (
<OLNotification
type="error"
content={
<>
{t('account_has_past_due_invoice_change_plan_warning')}{' '}
<a
href={subscription.payment.accountManagementLink}
target="_blank"
rel="noreferrer noopener"
>
{t('view_your_invoices')}
</a>
</>
}
/>
)
}
function RedirectAlerts() {
const queryParams = new URLSearchParams(window.location.search)
const redirectReason = queryParams.get('redirect-reason')
const { t } = useTranslation()
if (!redirectReason) {
return null
}
let warning
if (redirectReason === 'writefull-entitled') {
warning = t('good_news_you_are_already_receiving_this_add_on_via_writefull')
} else if (redirectReason === 'double-buy') {
warning = t('good_news_you_already_purchased_this_add_on')
} else {
return null
}
return <OLNotification type="warning" content={<>{warning}</>} />
}
function PersonalSubscriptionStates({
subscription,
}: {
subscription: PaidSubscription
}) {
const { t } = useTranslation()
const state = subscription?.payment.state
if (state === 'active') {
// This version handles subscriptions with and without addons
return <ActiveSubscriptionNew subscription={subscription} />
} else if (state === 'canceled') {
return <CanceledSubscription subscription={subscription} />
} else if (state === 'expired') {
return <ExpiredSubscription subscription={subscription} />
} else if (state === 'paused') {
return <PausedSubscription subscription={subscription} />
} else {
return <>{t('problem_with_subscription_contact_us')}</>
}
}
function PersonalSubscription() {
const { t } = useTranslation()
const { personalSubscription, recurlyLoadError } =
useSubscriptionDashboardContext()
if (!personalSubscription) return null
if (!('payment' in personalSubscription)) {
return (
<p>
<Trans
i18nKey="please_contact_support_to_makes_change_to_your_plan"
components={[<a href="/contact" />]} // eslint-disable-line react/jsx-key, jsx-a11y/anchor-has-content
/>
</p>
)
}
return (
<>
<RedirectAlerts />
{personalSubscription.payment.hasPastDueInvoice && (
<PastDueSubscriptionAlert subscription={personalSubscription} />
)}
<PersonalSubscriptionStates
subscription={personalSubscription as PaidSubscription}
/>
{recurlyLoadError && (
<OLNotification
type="warning"
content={<strong>{t('payment_provider_unreachable_error')}</strong>}
/>
)}
<hr />
<PersonalSubscriptionRecurlySyncEmail />
</>
)
}
export default PersonalSubscription

View File

@@ -0,0 +1,17 @@
import { Trans } from 'react-i18next'
function PremiumFeaturesLink() {
return (
<p>
<Trans
i18nKey="get_most_subscription_discover_premium_features"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a href="/learn/how-to/Overleaf_premium_features" />,
]}
/>
</p>
)
}
export default PremiumFeaturesLink

View File

@@ -0,0 +1,40 @@
import { useTranslation } from 'react-i18next'
import { postJSON } from '../../../../infrastructure/fetch-json'
import { reactivateSubscriptionUrl } from '../../data/subscription-url'
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()
const { isLoading, isSuccess, runAsync } = useAsync()
const location = useLocation()
const handleReactivate = () => {
runAsync(postJSON(reactivateSubscriptionUrl)).catch(debugConsole.error)
}
if (isSuccess) {
location.reload()
}
// Don't show the button to reactivate the subscription for managed users
if (getMeta('ol-cannot-reactivate-subscription')) {
return null
}
return (
<OLButton
variant="primary"
disabled={isLoading || isSuccess}
onClick={handleReactivate}
isLoading={isLoading}
>
{t('reactivate_subscription')}
</OLButton>
)
}
export default ReactivateSubscription

View File

@@ -0,0 +1,22 @@
import { SubscriptionDashboardProvider } from '../../context/subscription-dashboard-context'
import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n'
import SubscriptionDashboard from './subscription-dashboard'
import { SplitTestProvider } from '@/shared/context/split-test-context'
function Root() {
const { isReady } = useWaitForI18n()
if (!isReady) {
return null
}
return (
<SplitTestProvider>
<SubscriptionDashboardProvider>
<SubscriptionDashboard />
</SubscriptionDashboardProvider>
</SplitTestProvider>
)
}
export default Root

View File

@@ -0,0 +1,23 @@
import MaterialIcon from '../../../../shared/components/material-icon'
type RowLinkProps = {
href: string
heading: string
subtext: string
icon: string
}
export function RowLink({ href, heading, subtext, icon }: RowLinkProps) {
return (
<li className="list-group-item row-link">
<a href={href} className="row-link-inner">
<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

@@ -0,0 +1,379 @@
import { useTranslation, Trans } from 'react-i18next'
import { PriceExceptions } from '../../../shared/price-exceptions'
import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
import { CancelSubscriptionButton } from './cancel-subscription-button'
import { CancelSubscription } from './cancel-plan/cancel-subscription'
import { TrialEnding } from './trial-ending'
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 { CancelAiAddOnModal } from '@/features/subscription/components/dashboard/states/active/change-plan/modals/cancel-ai-add-on-modal'
import { WritefullBundleManagementModal } from '@/features/subscription/components/dashboard/states/active/change-plan/modals/writefull-bundle-management-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import isInFreeTrial from '../../../../util/is-in-free-trial'
import AddOns from '@/features/subscription/components/dashboard/states/active/add-ons'
import {
AI_ADD_ON_CODE,
AI_STANDALONE_PLAN_CODE,
isStandaloneAiPlanCode,
} from '@/features/subscription/data/add-on-codes'
import getMeta from '@/utils/meta'
import SubscriptionRemainder from '@/features/subscription/components/dashboard/states/active/subscription-remainder'
import { sendMB } from '../../../../../../infrastructure/event-tracking'
import PauseSubscriptionModal from '@/features/subscription/components/dashboard/pause-modal'
import LoadingSpinner from '@/shared/components/loading-spinner'
import { postJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import useAsync from '@/shared/hooks/use-async'
import { useLocation } from '@/shared/hooks/use-location'
import { FlashMessage } from '@/features/subscription/components/dashboard/states/active/flash-message'
import Notification from '@/shared/components/notification'
export function ActiveSubscriptionNew({
subscription,
}: {
subscription: PaidSubscription
}) {
const { t } = useTranslation()
const {
recurlyLoadError,
setModalIdShown,
showCancellation,
institutionMemberships,
memberGroupSubscriptions,
getFormattedRenewalDate,
} = useSubscriptionDashboardContext()
const cancelPauseReq = useAsync()
const { isError: isErrorPause } = cancelPauseReq
if (showCancellation) return <CancelSubscription />
const onStandalonePlan = isStandaloneAiPlanCode(subscription.planCode)
let planName
if (onStandalonePlan) {
planName = 'Overleaf Free'
if (institutionMemberships && institutionMemberships.length > 0) {
planName = 'Overleaf Professional'
}
if (memberGroupSubscriptions.length > 0) {
if (
memberGroupSubscriptions.some(s => s.planLevelName === 'Professional')
) {
planName = 'Overleaf Professional'
} else {
planName = 'Overleaf Standard'
}
}
} else {
planName = subscription.plan.name
}
const handlePlanChange = () => setModalIdShown('change-plan')
const handleManageOnWritefull = () => setModalIdShown('manage-on-writefull')
const handleCancelClick = (addOnCode: string) => {
if ([AI_STANDALONE_PLAN_CODE, AI_ADD_ON_CODE].includes(addOnCode)) {
setModalIdShown('cancel-ai-add-on')
}
}
const hasPendingPause = Boolean(
subscription.payment.state === 'active' &&
subscription.payment.remainingPauseCycles &&
subscription.payment.remainingPauseCycles > 0
)
const isLegacyPlan =
subscription.payment.totalLicenses !==
subscription.payment.additionalLicenses
return (
<>
<div className="notification-list">
<FlashMessage />
{isErrorPause && (
<Notification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
</div>
<h2 className="h3 fw-bold">{t('billing')}</h2>
<p className="mb-1">
{subscription.plan.annual ? (
<Trans
i18nKey="billed_annually_at"
values={{ price: subscription.payment.displayPrice }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
// eslint-disable-next-line react/jsx-key
<i />,
]}
/>
) : (
<Trans
i18nKey="billed_monthly_at"
values={{ price: subscription.payment.displayPrice }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
// eslint-disable-next-line react/jsx-key
<i />,
]}
/>
)}
</p>
<p className="mb-1">
<Trans
i18nKey="renews_on"
values={{ date: subscription.payment.nextPaymentDueDate }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[<strong />]} // eslint-disable-line react/jsx-key
/>
</p>
<div>
<a
href={subscription.payment.accountManagementLink}
target="_blank"
rel="noreferrer noopener"
className="me-2"
>
{t('view_invoices')}
</a>
<a
href={subscription.payment.billingDetailsLink}
target="_blank"
rel="noreferrer noopener"
>
{t('view_billing_details')}
</a>
</div>
<div className="mt-3">
<PriceExceptions subscription={subscription} />
{!recurlyLoadError && (
<p>
<i>
<SubscriptionRemainder subscription={subscription} hideTime />
</i>
</p>
)}
</div>
<hr />
<h2 className="h3 fw-bold">{t('plan')}</h2>
<h3 className="h5 mt-0 mb-1 fw-bold">{planName}</h3>
{subscription.pendingPlan &&
subscription.pendingPlan.name !== subscription.plan.name && (
<p className="mb-1">{t('want_change_to_apply_before_plan_end')}</p>
)}
{isInFreeTrial(subscription.payment.trialEndsAt) &&
subscription.payment.trialEndsAtFormatted && (
<TrialEnding
trialEndsAtFormatted={subscription.payment.trialEndsAtFormatted}
className="mb-1"
/>
)}
{subscription.payment.totalLicenses > 0 && (
<p className="mb-1">
{isLegacyPlan && subscription.payment.additionalLicenses > 0 ? (
<Trans
i18nKey="plus_x_additional_licenses_for_a_total_of_y_licenses"
values={{
count: subscription.payment.totalLicenses,
additionalLicenses: subscription.payment.additionalLicenses,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[<strong />, <strong />]} // eslint-disable-line react/jsx-key
/>
) : (
<Trans
i18nKey="supports_up_to_x_licenses"
values={{ count: subscription.payment.totalLicenses }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[<strong />]} // eslint-disable-line react/jsx-key
/>
)}
</p>
)}
{hasPendingPause && (
<>
<p>
<Trans
i18nKey="your_subscription_will_pause_on"
values={{
planName: subscription.plan.name,
pauseDate: subscription.payment.nextPaymentDueAt,
reactivationDate: getFormattedRenewalDate(),
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
</p>
<p>{t('you_can_still_use_your_premium_features')}</p>
</>
)}
{!onStandalonePlan && (
<p className="mb-1">
{subscription.plan.annual
? t('x_price_per_year', {
price: subscription.payment.planOnlyDisplayPrice,
})
: t('x_price_per_month', {
price: subscription.payment.planOnlyDisplayPrice,
})}
</p>
)}
{!recurlyLoadError && (
<PlanActions
subscription={subscription}
onStandalonePlan={onStandalonePlan}
handlePlanChange={handlePlanChange}
hasPendingPause={hasPendingPause}
cancelPauseReq={cancelPauseReq}
/>
)}
<hr />
<AddOns
subscription={subscription}
onStandalonePlan={onStandalonePlan}
handleCancelClick={handleCancelClick}
handleManageOnWritefull={handleManageOnWritefull}
/>
<ChangePlanModal />
<ConfirmChangePlanModal />
<KeepCurrentPlanModal />
<ChangeToGroupModal />
<CancelAiAddOnModal />
<WritefullBundleManagementModal />
<PauseSubscriptionModal />
</>
)
}
type PlanActionsProps = {
subscription: PaidSubscription
onStandalonePlan: boolean
handlePlanChange: () => void
hasPendingPause: boolean
cancelPauseReq: ReturnType<typeof useAsync>
}
function PlanActions({
subscription,
onStandalonePlan,
handlePlanChange,
hasPendingPause,
cancelPauseReq,
}: PlanActionsProps) {
const { t } = useTranslation()
const isSubscriptionEligibleForFlexibleGroupLicensing = getMeta(
'ol-canUseFlexibleLicensing'
)
const location = useLocation()
const { runAsync: runAsyncCancelPause, isLoading: isLoadingCancelPause } =
cancelPauseReq
const handleCancelPendingPauseClick = async () => {
try {
await runAsyncCancelPause(postJSON('/user/subscription/pause/0'))
const newUrl = new URL(location.toString())
newUrl.searchParams.set('flash', 'unpaused')
window.history.replaceState(null, '', newUrl)
location.reload()
} catch (e) {
debugConsole.error(e)
}
}
return (
<div className="mt-3">
{isSubscriptionEligibleForFlexibleGroupLicensing ? (
<FlexibleGroupLicensingActions subscription={subscription} />
) : (
<>
{!hasPendingPause && !subscription.payment.hasPastDueInvoice && (
<OLButton variant="secondary" onClick={handlePlanChange}>
{t('change_plan')}
</OLButton>
)}
</>
)}
{hasPendingPause && (
<OLButton
variant="primary"
onClick={handleCancelPendingPauseClick}
disabled={isLoadingCancelPause}
>
{isLoadingCancelPause ? (
<LoadingSpinner />
) : (
t('unpause_subscription')
)}
</OLButton>
)}
{!onStandalonePlan && (
<>
{' '}
<CancelSubscriptionButton />
</>
)}
</div>
)
}
function FlexibleGroupLicensingActions({
subscription,
}: {
subscription: PaidSubscription
}) {
const { t } = useTranslation()
if (subscription.pendingPlan) {
return null
}
const isProfessionalPlan = subscription.planCode
.toLowerCase()
.includes('professional')
return (
<>
{!isProfessionalPlan && (
<>
<OLButton
variant="secondary"
href="/user/subscription/group/upgrade-subscription"
onClick={() =>
sendMB('flex-upgrade', { location: 'upgrade-plan-button' })
}
>
{t('upgrade_plan')}
</OLButton>{' '}
</>
)}
{subscription.plan.membersLimitAddOn === 'additional-license' && (
<OLButton
variant="secondary"
href="/user/subscription/group/add-users"
onClick={() => sendMB('flex-add-users')}
>
{t('buy_more_licenses')}
</OLButton>
)}
</>
)
}

View File

@@ -0,0 +1,236 @@
import { useTranslation, Trans } from 'react-i18next'
import { PriceExceptions } from '../../../shared/price-exceptions'
import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
import { CancelSubscriptionButton } from './cancel-subscription-button'
import { CancelSubscription } from './cancel-plan/cancel-subscription'
import { PendingPlanChange } from './pending-plan-change'
import { TrialEnding } from './trial-ending'
import { PendingAdditionalLicenses } from './pending-additional-licenses'
import { ContactSupportToChangeGroupPlan } from './contact-support-to-change-group-plan'
import SubscriptionRemainder from './subscription-remainder'
import isInFreeTrial from '../../../../util/is-in-free-trial'
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'
import useAsync from '@/shared/hooks/use-async'
import { postJSON } from '@/infrastructure/fetch-json'
import PauseSubscriptionModal from '../../pause-modal'
import Notification from '@/shared/components/notification'
import { debugConsole } from '@/utils/debugging'
import { FlashMessage } from './flash-message'
import { useLocation } from '@/shared/hooks/use-location'
import LoadingSpinner from '@/shared/components/loading-spinner'
export function ActiveSubscription({
subscription,
}: {
subscription: PaidSubscription
}) {
const { t } = useTranslation()
const {
recurlyLoadError,
setModalIdShown,
showCancellation,
getFormattedRenewalDate,
} = useSubscriptionDashboardContext()
const {
isError: isErrorPause,
runAsync: runAsyncCancelPause,
isLoading: isLoadingCancelPause,
} = useAsync()
const location = useLocation()
if (showCancellation) return <CancelSubscription />
const hasPendingPause =
subscription.payment.state === 'active' &&
subscription.payment.remainingPauseCycles &&
subscription.payment.remainingPauseCycles > 0
const handleCancelPendingPauseClick = async () => {
try {
await runAsyncCancelPause(postJSON('/user/subscription/pause/0'))
const newUrl = new URL(location.toString())
newUrl.searchParams.set('flash', 'unpaused')
window.history.replaceState(null, '', newUrl)
location.reload()
} catch (e) {
debugConsole.error(e)
}
}
return (
<>
<div className="notification-list">
<FlashMessage />
{isErrorPause && (
<Notification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
</div>
<p>
{!hasPendingPause && (
<Trans
i18nKey="currently_subscribed_to_plan"
values={{
planName: subscription.plan.name,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
)}
{subscription.pendingPlan && (
<>
{' '}
<PendingPlanChange subscription={subscription} />
</>
)}
{!subscription.pendingPlan &&
subscription.payment.additionalLicenses > 0 && (
<>
{' '}
<PendingAdditionalLicenses
additionalLicenses={subscription.payment.additionalLicenses}
totalLicenses={subscription.payment.totalLicenses}
/>
</>
)}
{!recurlyLoadError &&
!subscription.groupPlan &&
!hasPendingPause &&
!subscription.payment.hasPastDueInvoice && (
<>
{' '}
<OLButton
variant="link"
className="btn-inline-link"
onClick={() => setModalIdShown('change-plan')}
>
{t('change_plan')}
</OLButton>
</>
)}
</p>
{subscription.pendingPlan &&
subscription.pendingPlan.name !== subscription.plan.name && (
<p>{t('want_change_to_apply_before_plan_end')}</p>
)}
{(!subscription.pendingPlan ||
subscription.pendingPlan.name === subscription.plan.name) &&
subscription.plan.groupPlan && <ContactSupportToChangeGroupPlan />}
{isInFreeTrial(subscription.payment.trialEndsAt) &&
subscription.payment.trialEndsAtFormatted && (
<TrialEnding
trialEndsAtFormatted={subscription.payment.trialEndsAtFormatted}
/>
)}
{hasPendingPause && (
<>
<p>
<Trans
i18nKey="your_subscription_will_pause_on"
values={{
planName: subscription.plan.name,
pauseDate: subscription.payment.nextPaymentDueAt,
reactivationDate: getFormattedRenewalDate(),
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
</p>
<p>{t('you_can_still_use_your_premium_features')}</p>
<p>
<OLButton
variant="primary"
onClick={handleCancelPendingPauseClick}
disabled={isLoadingCancelPause}
>
{isLoadingCancelPause ? (
<LoadingSpinner />
) : (
t('unpause_subscription')
)}
</OLButton>
</p>
</>
)}
<p>
<Trans
i18nKey="next_payment_of_x_collectected_on_y"
values={{
paymentAmmount: subscription.payment.displayPrice,
collectionDate: getFormattedRenewalDate(),
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
</p>
<hr />
<PriceExceptions subscription={subscription} />
<p className="d-inline-flex flex-wrap gap-1">
<a
href={subscription.payment.billingDetailsLink}
target="_blank"
rel="noreferrer noopener"
className="btn btn-secondary-info btn-secondary"
>
{t('update_your_billing_details')}
</a>{' '}
<a
href={subscription.payment.accountManagementLink}
target="_blank"
rel="noreferrer noopener"
className="btn btn-secondary-info btn-secondary"
>
{t('view_your_invoices')}
</a>
{!recurlyLoadError && (
<>
{' '}
<CancelSubscriptionButton />
</>
)}
</p>
{!recurlyLoadError && (
<>
<br />
<p>
<i>
<SubscriptionRemainder subscription={subscription} />
</i>
</p>
</>
)}
<ChangePlanModal />
<ConfirmChangePlanModal />
<KeepCurrentPlanModal />
<ChangeToGroupModal />
<PauseSubscriptionModal />
</>
)
}

View File

@@ -0,0 +1,202 @@
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import { Dropdown, DropdownMenu, DropdownToggle } from 'react-bootstrap-5'
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import MaterialIcon from '@/shared/components/material-icon'
import {
ADD_ON_NAME,
AI_ADD_ON_CODE,
AI_STANDALONE_ANNUAL_PLAN_CODE,
AI_STANDALONE_PLAN_CODE,
} from '@/features/subscription/data/add-on-codes'
import sparkle from '@/shared/svgs/sparkle.svg'
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
import { LICENSE_ADD_ON } from '@/features/group-management/components/upgrade-subscription/upgrade-subscription-plan-details'
type AddOnsProps = {
subscription: PaidSubscription
onStandalonePlan: boolean
handleCancelClick: (code: string) => void
handleManageOnWritefull: () => void
}
type AddOnProps = {
addOnCode: string
displayPrice: string | undefined
pendingCancellation: boolean
isAnnual: boolean
handleCancelClick: (code: string) => void
nextBillingDate: string
}
function resolveAddOnName(addOnCode: string) {
switch (addOnCode) {
case AI_ADD_ON_CODE:
case AI_STANDALONE_ANNUAL_PLAN_CODE:
case AI_STANDALONE_PLAN_CODE:
return ADD_ON_NAME
}
}
function AddOn({
addOnCode,
displayPrice,
pendingCancellation,
isAnnual,
handleCancelClick,
nextBillingDate,
}: AddOnProps) {
const { t } = useTranslation()
return (
<div className="add-on-card">
<div>
<img
alt="sparkle"
className="add-on-card-icon"
src={sparkle}
aria-hidden="true"
/>
</div>
<div className="add-on-card-content">
<div className="heading">{resolveAddOnName(addOnCode)}</div>
<div className="description small mt-1">
{pendingCancellation
? t(
'your_add_on_has_been_cancelled_and_will_remain_active_until_your_billing_cycle_ends_on',
{ nextBillingDate }
)
: isAnnual
? t('x_price_per_year', { price: displayPrice })
: t('x_price_per_month', { price: displayPrice })}
</div>
</div>
{!pendingCancellation && (
<div className="ms-auto">
<Dropdown align="end">
<DropdownToggle
id="add-on-dropdown-toggle"
className="add-on-options-toggle"
variant="secondary"
>
<MaterialIcon
type="more_vert"
accessibilityLabel={t('more_options')}
/>
</DropdownToggle>
<DropdownMenu flip={false}>
<OLDropdownMenuItem
onClick={() => handleCancelClick(addOnCode)}
as="button"
tabIndex={-1}
variant="danger"
>
{t('cancel')}
</OLDropdownMenuItem>
</DropdownMenu>
</Dropdown>
</div>
)}
</div>
)
}
function WritefullGrantedAddOn({
handleManageOnWritefull,
}: {
handleManageOnWritefull: () => void
}) {
const { t } = useTranslation()
return (
<div className="add-on-card">
<div>
<img
alt="sparkle"
className="add-on-card-icon"
src={sparkle}
aria-hidden="true"
/>
</div>
<div className="add-on-card-content">
<div className="heading">{ADD_ON_NAME}</div>
<div className="description small mt-1">
{t('included_as_part_of_your_writefull_subscription')}
</div>
</div>
<div className="ms-auto">
<Dropdown align="end">
<DropdownToggle
id="add-on-dropdown-toggle"
className="add-on-options-toggle"
variant="secondary"
>
<MaterialIcon
type="more_vert"
accessibilityLabel={t('more_options')}
/>
</DropdownToggle>
<DropdownMenu flip={false}>
<OLDropdownMenuItem tabIndex={-1} onClick={handleManageOnWritefull}>
{t('manage_subscription')}
</OLDropdownMenuItem>
</DropdownMenu>
</Dropdown>
</div>
</div>
)
}
function AddOns({
subscription,
onStandalonePlan,
handleCancelClick,
handleManageOnWritefull,
}: AddOnsProps) {
const { t } = useTranslation()
const hasAiAssistViaWritefull = getMeta('ol-hasAiAssistViaWritefull')
const addOnsDisplayPrices = onStandalonePlan
? {
[AI_STANDALONE_PLAN_CODE]: subscription.payment.displayPrice,
}
: subscription.payment.addOnDisplayPricesWithoutAdditionalLicense
const addOnsToDisplay = onStandalonePlan
? [{ addOnCode: AI_STANDALONE_PLAN_CODE }]
: subscription.addOns?.filter(addOn => addOn.addOnCode !== LICENSE_ADD_ON)
const hasAddons =
(addOnsToDisplay && addOnsToDisplay.length > 0) || hasAiAssistViaWritefull
return (
<>
<h2 className="h3 fw-bold">{t('add_ons')}</h2>
{hasAddons ? (
<>
{addOnsToDisplay?.map(addOn => (
<AddOn
addOnCode={addOn.addOnCode}
key={addOn.addOnCode}
isAnnual={Boolean(subscription.plan.annual)}
handleCancelClick={handleCancelClick}
pendingCancellation={
subscription.pendingPlan !== undefined &&
(subscription.pendingPlan.addOns ?? []).every(
pendingAddOn => pendingAddOn.code !== addOn.addOnCode
)
}
displayPrice={addOnsDisplayPrices[addOn.addOnCode]}
nextBillingDate={subscription.payment.nextPaymentDueDate}
/>
))}
{hasAiAssistViaWritefull && (
<WritefullGrantedAddOn
handleManageOnWritefull={handleManageOnWritefull}
/>
)}
</>
) : (
<p>{t('you_dont_have_any_add_ons_on_your_account')}</p>
)}
</>
)
}
export default AddOns

View File

@@ -0,0 +1,213 @@
import { Trans, useTranslation } from 'react-i18next'
import { Plan } from '../../../../../../../../../types/subscription/plan'
import { postJSON } from '../../../../../../../infrastructure/fetch-json'
import LoadingSpinner from '../../../../../../../shared/components/loading-spinner'
import useAsync from '../../../../../../../shared/hooks/use-async'
import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
import {
cancelSubscriptionUrl,
redirectAfterCancelSubscriptionUrl,
} from '../../../../../data/subscription-url'
import showDowngradeOption from '../../../../../util/show-downgrade-option'
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({
showNoThanks,
onClick,
disabled,
isLoading,
}: {
showNoThanks: boolean
onClick: () => void
disabled: boolean
isLoading: boolean
}) {
const { t } = useTranslation()
const text = showNoThanks ? t('no_thanks_cancel_now') : t('cancel_my_account')
return (
<OLButton
isLoading={isLoading}
loadingLabel={t('processing_uppercase') + '…'}
disabled={disabled}
onClick={onClick}
variant={showNoThanks ? 'link' : undefined}
>
{text}
</OLButton>
)
}
function NotCancelOption({
isButtonDisabled,
isLoadingSecondaryAction,
isSuccessSecondaryAction,
planToDowngradeTo,
showExtendFreeTrial,
showDowngrade,
runAsyncSecondaryAction,
}: {
isButtonDisabled: boolean
isLoadingSecondaryAction: boolean
isSuccessSecondaryAction: boolean
planToDowngradeTo?: Plan
showExtendFreeTrial: boolean
showDowngrade: boolean
runAsyncSecondaryAction: (promise: Promise<unknown>) => Promise<unknown>
}) {
const { t } = useTranslation()
const { setShowCancellation } = useSubscriptionDashboardContext()
if (showExtendFreeTrial) {
return (
<>
<p>
<Trans
i18nKey="have_more_days_to_try"
values={{
days: 14,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={{ strong: <strong /> }}
/>
</p>
<p>
<ExtendTrialButton
isButtonDisabled={isButtonDisabled}
isLoading={isLoadingSecondaryAction || isSuccessSecondaryAction}
runAsyncSecondaryAction={runAsyncSecondaryAction}
/>
</p>
</>
)
}
if (showDowngrade && planToDowngradeTo) {
return (
<>
<p>
<Trans
i18nKey="interested_in_cheaper_personal_plan"
values={{
price: planToDowngradeTo.displayPrice,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
</p>
<p>
<DowngradePlanButton
isButtonDisabled={isButtonDisabled}
isLoading={isLoadingSecondaryAction || isSuccessSecondaryAction}
planToDowngradeTo={planToDowngradeTo}
runAsyncSecondaryAction={runAsyncSecondaryAction}
/>
</p>
</>
)
}
function handleKeepPlan() {
setShowCancellation(false)
}
return (
<p>
<OLButton variant="secondary" onClick={handleKeepPlan}>
{t('i_want_to_stay')}
</OLButton>
</p>
)
}
export function CancelSubscription() {
const { t } = useTranslation()
const location = useLocation()
const { personalSubscription, plans, userCanExtendTrial } =
useSubscriptionDashboardContext()
const {
isLoading: isLoadingCancel,
isError: isErrorCancel,
isSuccess: isSuccessCancel,
runAsync: runAsyncCancel,
} = useAsync()
const {
isLoading: isLoadingSecondaryAction,
isError: isErrorSecondaryAction,
isSuccess: isSuccessSecondaryAction,
runAsync: runAsyncSecondaryAction,
} = useAsync()
const isButtonDisabled =
isLoadingCancel ||
isLoadingSecondaryAction ||
isSuccessSecondaryAction ||
isSuccessCancel
if (!personalSubscription || !('payment' in personalSubscription)) return null
const showDowngrade = showDowngradeOption(
personalSubscription.plan.planCode,
personalSubscription.plan.groupPlan,
personalSubscription.payment.trialEndsAt,
personalSubscription.payment.pausedAt,
personalSubscription.payment.remainingPauseCycles
)
const planToDowngradeTo = plans.find(
plan => plan.planCode === planCodeToDowngradeTo
)
if (showDowngrade && !planToDowngradeTo) {
return <LoadingSpinner />
}
async function handleCancelSubscription() {
try {
await runAsyncCancel(postJSON(cancelSubscriptionUrl))
location.assign(redirectAfterCancelSubscriptionUrl)
} catch (e) {
debugConsole.error(e)
}
}
const showExtendFreeTrial = userCanExtendTrial
return (
<>
<div className="text-center">
<p>
<strong>{t('wed_love_you_to_stay')}</strong>
</p>
{(isErrorCancel || isErrorSecondaryAction) && <GenericErrorAlert />}
<NotCancelOption
showExtendFreeTrial={showExtendFreeTrial}
showDowngrade={showDowngrade}
isButtonDisabled={isButtonDisabled}
isLoadingSecondaryAction={isLoadingSecondaryAction}
isSuccessSecondaryAction={isSuccessSecondaryAction}
planToDowngradeTo={planToDowngradeTo}
runAsyncSecondaryAction={runAsyncSecondaryAction}
/>
<ConfirmCancelSubscriptionButton
showNoThanks={showExtendFreeTrial || showDowngrade}
onClick={handleCancelSubscription}
disabled={isButtonDisabled}
isLoading={isSuccessCancel || isLoadingCancel}
/>
</div>
</>
)
}

View File

@@ -0,0 +1,48 @@
import { useTranslation } from 'react-i18next'
import { Plan } from '../../../../../../../../../types/subscription/plan'
import { postJSON } from '../../../../../../../infrastructure/fetch-json'
import { subscriptionUpdateUrl } from '../../../../../data/subscription-url'
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,
isLoading,
planToDowngradeTo,
runAsyncSecondaryAction,
}: {
isButtonDisabled: boolean
isLoading: boolean
planToDowngradeTo: Plan
runAsyncSecondaryAction: (promise: Promise<unknown>) => Promise<unknown>
}) {
const { t } = useTranslation()
const location = useLocation()
const buttonText = t('yes_move_me_to_personal_plan')
async function handleDowngradePlan() {
try {
await runAsyncSecondaryAction(
postJSON(`${subscriptionUpdateUrl}?downgradeToPaidPersonal`, {
body: { plan_code: planToDowngradeTo.planCode },
})
)
location.reload()
} catch (e) {
debugConsole.error(e)
}
}
return (
<OLButton
variant="primary"
onClick={handleDowngradePlan}
disabled={isButtonDisabled}
isLoading={isLoading}
loadingLabel={t('processing_uppercase') + '…'}
>
{buttonText}
</OLButton>
)
}

View File

@@ -0,0 +1,41 @@
import { useTranslation } from 'react-i18next'
import { putJSON } from '../../../../../../../infrastructure/fetch-json'
import { extendTrialUrl } from '../../../../../data/subscription-url'
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,
isLoading,
runAsyncSecondaryAction,
}: {
isButtonDisabled: boolean
isLoading: boolean
runAsyncSecondaryAction: (promise: Promise<unknown>) => Promise<unknown>
}) {
const { t } = useTranslation()
const buttonText = t('ill_take_it')
const location = useLocation()
async function handleExtendTrial() {
try {
await runAsyncSecondaryAction(putJSON(extendTrialUrl))
location.reload()
} catch (e) {
debugConsole.error(e)
}
}
return (
<OLButton
variant="primary"
onClick={handleExtendTrial}
disabled={isButtonDisabled}
isLoading={isLoading}
loadingLabel={t('processing_uppercase') + '…'}
>
{buttonText}
</OLButton>
)
}

View File

@@ -0,0 +1,54 @@
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'
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export function CancelSubscriptionButton() {
const { t } = useTranslation()
const {
recurlyLoadError,
personalSubscription,
setModalIdShown,
setShowCancellation,
} = useSubscriptionDashboardContext()
const subscription = personalSubscription as PaidSubscription
const isInTrial =
subscription?.payment.trialEndsAtFormatted &&
subscription?.payment.trialEndsAt &&
new Date(subscription.payment.trialEndsAt).getTime() > Date.now()
const hasPendingOrActivePause =
subscription.payment.state === 'paused' ||
(subscription.payment.state === 'active' &&
subscription.payment.remainingPauseCycles &&
subscription.payment.remainingPauseCycles > 0)
const planIsEligibleForPause =
!subscription.pendingPlan &&
!subscription.groupPlan &&
!isInTrial &&
!subscription.planCode.includes('ann') &&
!subscription.addOns?.length
const enablePause =
useFeatureFlag('pause-subscription') &&
!hasPendingOrActivePause &&
planIsEligibleForPause
function handleCancelSubscriptionClick() {
eventTracking.sendMB('subscription-page-cancel-button-click', {
plan_code: subscription?.planCode,
is_trial: isInTrial,
})
if (enablePause) setModalIdShown('pause-subscription')
else setShowCancellation(true)
}
if (recurlyLoadError) return null
return (
<OLButton variant="danger-ghost" onClick={handleCancelSubscriptionClick}>
{t('cancel_your_subscription')}
</OLButton>
)
}

View File

@@ -0,0 +1,51 @@
import { useTranslation } from 'react-i18next'
import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import isInFreeTrial from '../../../../../util/is-in-free-trial'
import { PaidSubscription } from '../../../../../../../../../types/subscription/dashboard/subscription'
export function ChangeToGroupPlan() {
const { t } = useTranslation()
const { handleOpenModal, personalSubscription } =
useSubscriptionDashboardContext()
// TODO: Better way to get PaidSubscription/trialEndsAt
const subscription =
personalSubscription && 'payment' in personalSubscription
? (personalSubscription as PaidSubscription)
: null
const handleClick = () => {
handleOpenModal('change-to-group')
}
return (
<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 />
{isInFreeTrial(subscription?.payment?.trialEndsAt) ? (
<>
<OLTooltip
id="disabled-change-to-group-plan"
description={t(
'sorry_you_can_only_change_to_group_from_trial_via_support'
)}
overlayProps={{ placement: 'top' }}
>
<div>
<OLButton variant="primary" disabled>
{t('change_to_group_plan')}
</OLButton>
</div>
</OLTooltip>
</>
) : (
<OLButton variant="primary" onClick={handleClick}>
{t('change_to_group_plan')}
</OLButton>
)}
</div>
)
}

View File

@@ -0,0 +1,124 @@
import { useMemo } from 'react'
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()
const { handleOpenModal } = useSubscriptionDashboardContext()
const handleClick = () => {
handleOpenModal('change-to-plan', planCode)
}
return (
<OLButton variant="primary" onClick={handleClick}>
{t('change_to_this_plan')}
</OLButton>
)
}
function KeepCurrentPlanButton({ plan }: { plan: Plan }) {
const { t } = useTranslation()
const { handleOpenModal } = useSubscriptionDashboardContext()
const handleClick = () => {
handleOpenModal('keep-current-plan')
}
return (
<OLButton variant="primary" onClick={handleClick}>
{t('keep_current_plan')}
</OLButton>
)
}
function ChangePlanButton({ plan }: { plan: Plan }) {
const { t } = useTranslation()
const { personalSubscription } = useSubscriptionDashboardContext()
const isCurrentPlanForUser =
personalSubscription?.planCode &&
plan.planCode === personalSubscription.planCode.split('_')[0]
if (isCurrentPlanForUser && personalSubscription.pendingPlan) {
return <KeepCurrentPlanButton plan={plan} />
} else if (isCurrentPlanForUser && !personalSubscription.pendingPlan) {
return (
<b>
<Icon type="check" /> {t('your_plan')}
</b>
)
} else if (
personalSubscription?.pendingPlan?.planCode?.split('_')[0] === plan.planCode
) {
return (
<b>
<Icon type="check" /> {t('your_new_plan')}
</b>
)
} else {
return <ChangeToPlanButton planCode={plan.planCode} />
}
}
function PlansRow({ plan }: { plan: Plan }) {
const { t } = useTranslation()
return (
<tr>
<td className="align-middle">
<strong>{plan.name}</strong>
</td>
<td className="align-middle">
{plan.displayPrice} / {plan.annual ? t('year') : t('month')}
</td>
<td className="align-middle text-center">
<ChangePlanButton plan={plan} />
</td>
</tr>
)
}
function PlansRows({ plans }: { plans: Array<Plan> }) {
return (
<>
{plans &&
plans.map(plan => (
<PlansRow key={`plans-row-${plan.planCode}`} plan={plan} />
))}
</>
)
}
export function IndividualPlansTable({ plans }: { plans: Array<Plan> }) {
const { t } = useTranslation()
const { recurlyLoadError } = useSubscriptionDashboardContext()
const filteredPlans = useMemo(
() =>
plans?.filter(
plan =>
!['paid-personal', 'paid-personal-annual'].includes(plan.planCode)
),
[plans]
)
if (!filteredPlans || recurlyLoadError) return null
return (
<table className="table align-middle table-vertically-centered-cells m-0">
<thead>
<tr className="d-none d-md-table-row">
<th>{t('name')}</th>
<th>{t('price')}</th>
<th />
</tr>
</thead>
<tbody>
<PlansRows plans={filteredPlans} />
</tbody>
</table>
)
}

View File

@@ -0,0 +1,116 @@
import { useState } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
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'
import {
AI_ADD_ON_CODE,
ADD_ON_NAME,
isStandaloneAiPlanCode,
} from '../../../../../../data/add-on-codes'
import {
cancelSubscriptionUrl,
redirectAfterCancelSubscriptionUrl,
} from '../../../../../../data/subscription-url'
export function CancelAiAddOnModal() {
const modalId: SubscriptionDashModalIds = 'cancel-ai-add-on'
const [error, setError] = useState(false)
const [inflight, setInflight] = useState(false)
const { t } = useTranslation()
const { handleCloseModal, modalIdShown, personalSubscription } =
useSubscriptionDashboardContext()
const location = useLocation()
if (!personalSubscription) return null
const onStandalone = isStandaloneAiPlanCode(personalSubscription.planCode)
const cancellationEndpoint = onStandalone
? cancelSubscriptionUrl
: `/user/subscription/addon/${AI_ADD_ON_CODE}/remove`
async function handleConfirmChange() {
setError(false)
setInflight(true)
try {
await postJSON(cancellationEndpoint)
location.assign(redirectAfterCancelSubscriptionUrl)
} catch (e) {
setError(true)
setInflight(false)
}
}
if (modalIdShown !== modalId) return null
return (
<OLModal
id={modalId}
show
animation
onHide={handleCloseModal}
backdrop="static"
>
<OLModalHeader>
<OLModalTitle>{t('cancel_add_on')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{error && (
<OLNotification
type="error"
aria-live="polite"
content={
<>
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
{t('generic_if_problem_continues_contact_us')}.
</>
}
/>
)}
<p>
<Trans
i18nKey="are_you_sure_you_want_to_cancel_add_on"
values={{
addOnName: ADD_ON_NAME,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={{ strong: <strong /> }}
/>
</p>
<p>{t('the_add_on_will_remain_active_until')}</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
disabled={inflight}
onClick={handleCloseModal}
>
{t('back')}
</OLButton>
<OLButton
variant="danger"
disabled={inflight}
isLoading={inflight}
loadingLabel={t('processing_uppercase') + '…'}
onClick={handleConfirmChange}
>
{t('cancel_add_on')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,55 @@
import { useTranslation } from 'react-i18next'
import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
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 } =
useSubscriptionDashboardContext()
if (!plans || recurlyLoadError) return null
if (queryingIndividualPlansData) {
return (
<>
<LoadingSpinner />
</>
)
} else {
return (
<>
<div className="border rounded px-2 pt-1 table-outlined-container">
<IndividualPlansTable plans={plans} />
</div>
<ChangeToGroupPlan />
</>
)
}
}
export function ChangePlanModal() {
const modalId: SubscriptionDashModalIds = 'change-plan'
const { t } = useTranslation()
const { handleCloseModal, modalIdShown } = useSubscriptionDashboardContext()
if (modalIdShown !== modalId) return null
return (
<OLModal id={modalId} show animation onHide={handleCloseModal} size="lg">
<OLModalHeader closeButton>
<OLModalTitle>{t('change_plan')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<ChangePlanOptions />
</OLModalBody>
</OLModal>
)
}

View File

@@ -0,0 +1,348 @@
import { useEffect, useState } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import { PaidSubscription } from '../../../../../../../../../../types/subscription/dashboard/subscription'
import { PriceForDisplayData } from '../../../../../../../../../../types/subscription/plan'
import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
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 OLNotification from '@/features/ui/components/ol/ol-notification'
const educationalPercentDiscount = 40
function GroupPlanCollaboratorCount({ planCode }: { planCode: string }) {
const { t } = useTranslation()
if (planCode === 'collaborator') {
return (
<>
{t('collabs_per_proj', {
collabcount: 10,
})}
</>
)
} else if (planCode === 'professional') {
return <>{t('unlimited_collabs')}</>
}
return null
}
function GroupPrice({
groupPlanToChangeToPrice,
queryingGroupPlanToChangeToPrice,
}: {
groupPlanToChangeToPrice?: PriceForDisplayData
queryingGroupPlanToChangeToPrice: boolean
}) {
const { t } = useTranslation()
const totalPrice =
!queryingGroupPlanToChangeToPrice &&
groupPlanToChangeToPrice?.totalForDisplay
? groupPlanToChangeToPrice.totalForDisplay
: '…'
const perUserPrice =
!queryingGroupPlanToChangeToPrice &&
groupPlanToChangeToPrice?.perUserDisplayPrice
? groupPlanToChangeToPrice.perUserDisplayPrice
: '…'
return (
<>
<span aria-hidden>
{totalPrice} <span className="small">/ {t('year')}</span>
</span>
<span className="sr-only">
{queryingGroupPlanToChangeToPrice
? t('loading_prices')
: t('x_price_per_year', {
price: groupPlanToChangeToPrice?.totalForDisplay,
})}
</span>
<span className="circle-subtext">
<span aria-hidden>
{t('x_price_per_user', {
price: perUserPrice,
})}
</span>
<span className="sr-only">
{queryingGroupPlanToChangeToPrice
? t('loading_prices')
: t('x_price_per_user', {
price: perUserPrice,
})}
</span>
</span>
</>
)
}
export function ChangeToGroupModal() {
const modalId = 'change-to-group'
const { t } = useTranslation()
const {
groupPlanToChangeToCode,
groupPlanToChangeToPrice,
groupPlanToChangeToPriceError,
groupPlanToChangeToSize,
groupPlanToChangeToUsage,
handleCloseModal,
modalIdShown,
queryingGroupPlanToChangeToPrice,
setGroupPlanToChangeToCode,
setGroupPlanToChangeToSize,
setGroupPlanToChangeToUsage,
} = useSubscriptionDashboardContext()
const { modal: contactModal, showModal: showContactModal } =
useContactUsModal({ autofillProjectUrl: false })
const groupPlans = getMeta('ol-groupPlans')
const showGroupDiscount = getMeta('ol-showGroupDiscount')
const personalSubscription = getMeta('ol-subscription') as PaidSubscription
const [error, setError] = useState(false)
const [inflight, setInflight] = useState(false)
const location = useLocation()
async function upgrade() {
setError(false)
setInflight(true)
try {
await postJSON(subscriptionUpdateUrl, {
body: {
plan_code: getRecurlyGroupPlanCode(
groupPlanToChangeToCode,
groupPlanToChangeToSize,
groupPlanToChangeToUsage
),
},
})
location.reload()
} catch (e) {
setError(true)
setInflight(false)
}
}
useEffect(() => {
if (personalSubscription.plan.planCode.includes('professional')) {
setGroupPlanToChangeToCode('professional')
}
}, [personalSubscription, setGroupPlanToChangeToCode])
if (
modalIdShown !== modalId ||
!groupPlans ||
!groupPlans.plans ||
!groupPlans.sizes ||
!groupPlanToChangeToCode
)
return null
return (
<>
<UserProvider>{contactModal}</UserProvider>
<OLModal
id={modalId}
show
animation
onHide={handleCloseModal}
backdrop="static"
>
<OLModalHeader closeButton>
<OLModalTitle className="lh-sm">
{t('customize_your_group_subscription')}
{showGroupDiscount && (
<p className="group-subscription-modal-title-discount">
{t('save_x_or_more', { percentage: '10%' })}
</p>
)}
</OLModalTitle>
</OLModalHeader>
<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 mb-4 mx-auto">
<GroupPrice
groupPlanToChangeToPrice={groupPlanToChangeToPrice}
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 className="col-md-6">
<form className="form">
<fieldset className="form-group">
<legend className="legend-as-label">{t('plan')}</legend>
{groupPlans.plans.map(option => (
<div key={option.code}>
<OLFormCheckbox
type="radio"
name="plan-code"
value={option.code}
id={`plan-option-${option.code}`}
onChange={() =>
setGroupPlanToChangeToCode(option.code)
}
checked={option.code === groupPlanToChangeToCode}
label={option.display}
/>
</div>
))}
</fieldset>
<OLFormGroup controlId="size">
<OLFormLabel>{t('number_of_users')}</OLFormLabel>
<OLFormSelect
name="size"
value={groupPlanToChangeToSize}
onChange={e => setGroupPlanToChangeToSize(e.target.value)}
>
{groupPlans.sizes.map(size => (
<option key={`size-option-${size}`}>{size}</option>
))}
</OLFormSelect>
</OLFormGroup>
<OLFormCheckbox
id="usage"
type="checkbox"
autoComplete="off"
checked={groupPlanToChangeToUsage === 'educational'}
onChange={e => {
if (e.target.checked) {
setGroupPlanToChangeToUsage('educational')
} else {
setGroupPlanToChangeToUsage('enterprise')
}
}}
label={t(
'apply_educational_discount_description_with_group_discount'
)}
/>
</form>
</div>
</div>
<div className="educational-discount-badge pt-4 text-center">
{groupPlanToChangeToUsage === 'educational' && (
<p className="applied">
{t('educational_percent_discount_applied', {
percent: educationalPercentDiscount,
})}
</p>
)}
</div>
</div>
</OLModalBody>
<OLModalFooter>
<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 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="lg"
disabled={
queryingGroupPlanToChangeToPrice ||
!groupPlanToChangeToPrice ||
inflight
}
onClick={upgrade}
isLoading={inflight}
loadingLabel={t('processing_uppercase') + '…'}
>
{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: 20,
})}{' '}
{t('please_get_in_touch')}
</OLButton>
</div>
</OLModalFooter>
</OLModal>
</>
)
}

View File

@@ -0,0 +1,121 @@
import { useState } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
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'
const [error, setError] = useState(false)
const [inflight, setInflight] = useState(false)
const { t } = useTranslation()
const { handleCloseModal, modalIdShown, plans, planCodeToChangeTo } =
useSubscriptionDashboardContext()
const planCodesChangingAtTermEnd = getMeta('ol-planCodesChangingAtTermEnd')
const location = useLocation()
async function handleConfirmChange() {
setError(false)
setInflight(true)
try {
await postJSON(`${subscriptionUpdateUrl}?origin=confirmChangePlan`, {
body: {
plan_code: planCodeToChangeTo,
},
})
location.reload()
} catch (e) {
setError(true)
setInflight(false)
}
}
if (modalIdShown !== modalId || !planCodeToChangeTo) return null
const plan = plans.find(p => p.planCode === planCodeToChangeTo)
if (!plan) return null
const planWillChangeAtTermEnd =
planCodesChangingAtTermEnd &&
planCodesChangingAtTermEnd.indexOf(planCodeToChangeTo) > -1
return (
<OLModal
id={modalId}
show
animation
onHide={handleCloseModal}
backdrop="static"
>
<OLModalHeader>
<OLModalTitle>{t('change_plan')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{error && (
<OLNotification
type="error"
aria-live="polite"
content={
<>
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
{t('generic_if_problem_continues_contact_us')}.
</>
}
/>
)}
<p>
<Trans
i18nKey="sure_you_want_to_change_plan"
values={{
planName: plan.name,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
</p>
{planWillChangeAtTermEnd && (
<>
<p>{t('existing_plan_active_until_term_end')}</p>
<p>{t('want_change_to_apply_before_plan_end')}</p>
</>
)}
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
disabled={inflight}
onClick={handleCloseModal}
>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
disabled={inflight}
isLoading={inflight}
loadingLabel={t('processing_uppercase') + '…'}
onClick={handleConfirmChange}
>
{t('change_plan')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,102 @@
import { useState } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
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'
const [error, setError] = useState(false)
const [inflight, setInflight] = useState(false)
const { t } = useTranslation()
const location = useLocation()
const { modalIdShown, handleCloseModal, personalSubscription } =
useSubscriptionDashboardContext()
async function confirmCancelPendingPlanChange() {
setError(false)
setInflight(true)
try {
await postJSON(cancelPendingSubscriptionChangeUrl)
location.reload()
} catch (e) {
setError(true)
setInflight(false)
}
}
if (modalIdShown !== modalId || !personalSubscription) return null
return (
<OLModal
id={modalId}
show
animation
onHide={handleCloseModal}
backdrop="static"
>
<OLModalHeader>
<OLModalTitle>{t('change_plan')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{error && (
<OLNotification
type="error"
aria-live="polite"
content={
<>
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
{t('generic_if_problem_continues_contact_us')}.
</>
}
/>
)}
<p>
<Trans
i18nKey="sure_you_want_to_cancel_plan_change"
values={{
planName: personalSubscription.plan.name,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
disabled={inflight}
onClick={handleCloseModal}
>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
disabled={inflight}
isLoading={inflight}
loadingLabel={t('processing_uppercase') + '…'}
onClick={confirmCancelPendingPlanChange}
>
{t('revert_pending_plan_change')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,51 @@
import { useTranslation } from 'react-i18next'
import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
export function WritefullBundleManagementModal() {
const modalId: SubscriptionDashModalIds = 'manage-on-writefull'
const { t } = useTranslation()
const { handleCloseModal, modalIdShown } = useSubscriptionDashboardContext()
if (modalIdShown !== modalId) return null
return (
<OLModal
id={modalId}
show
animation
onHide={handleCloseModal}
backdrop="static"
>
<OLModalHeader>
<OLModalTitle>{t('manage_your_ai_assist_add_on')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>{t('ai_assist_in_overleaf_is_included_via_writefull')}</p>
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={handleCloseModal}>
{t('back')}
</OLButton>
<OLButton
variant="primary"
onClick={handleCloseModal}
href="https://my.writefull.com/account"
target="_blank"
rel="noreferrer"
>
{t('go_to_writefull')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,103 @@
import { useState } from 'react'
import { SubscriptionDashModalIds } from '../../../../../../../../types/subscription/dashboard/modal-ids'
import { Trans, useTranslation } from 'react-i18next'
import { useSubscriptionDashboardContext } from '@/features/subscription/context/subscription-dashboard-context'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import { postJSON } from '@/infrastructure/fetch-json'
import { useLocation } from '@/shared/hooks/use-location'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
export function ConfirmUnpauseSubscriptionModal() {
const modalId: SubscriptionDashModalIds = 'unpause-subscription'
const [error, setError] = useState(false)
const [inflight, setInflight] = useState(false)
const { t } = useTranslation()
const { handleCloseModal, modalIdShown, personalSubscription } =
useSubscriptionDashboardContext()
const location = useLocation()
const subscription = personalSubscription as PaidSubscription
async function handleConfirmUnpause() {
setError(false)
setInflight(true)
try {
await postJSON('/user/subscription/resume')
const newUrl = new URL(location.toString())
newUrl.searchParams.set('flash', 'unpaused')
window.history.replaceState(null, '', newUrl)
location.reload()
} catch (e) {
setError(true)
setInflight(false)
}
}
if (modalIdShown !== modalId) return null
return (
<OLModal
id={modalId}
show
animation
onHide={handleCloseModal}
backdrop="static"
>
<OLModalHeader>
<OLModalTitle>{t('pick_up_where_you_left_off')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{error && (
<OLNotification
type="error"
aria-live="polite"
content={
<>
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
{t('generic_if_problem_continues_contact_us')}.
</>
}
/>
)}
<p>
<Trans
i18nKey="lets_get_those_premium_features"
values={{
paymentAmount: subscription.payment.displayPrice,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
</p>
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
disabled={inflight}
onClick={handleCloseModal}
>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
disabled={inflight}
isLoading={inflight}
onClick={handleConfirmUnpause}
>
{t('unpause_subscription')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,15 @@
import { Trans } from 'react-i18next'
export function ContactSupportToChangeGroupPlan() {
return (
<p>
<Trans
i18nKey="contact_support_to_change_group_subscription"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a href="/contact" />,
]}
/>
</p>
)
}

View File

@@ -0,0 +1,61 @@
import { useSubscriptionDashboardContext } from '@/features/subscription/context/subscription-dashboard-context'
import Notification from '@/shared/components/notification'
import { Trans, useTranslation } from 'react-i18next'
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
import { useEffect, useState } from 'react'
import { useLocation } from '@/shared/hooks/use-location'
export type FlashMessageName = 'paused' | 'unpaused' | 'error'
export function FlashMessage() {
const { t } = useTranslation()
const { personalSubscription } = useSubscriptionDashboardContext()
const location = useLocation()
const [message] = useState(
// eslint-disable-next-line no-restricted-syntax
new URL(window.location.toString()).searchParams.get(
'flash'
) as FlashMessageName
)
const subscription = personalSubscription as PaidSubscription
useEffect(() => {
// clear any flash message IDs so they only show once
if (location.toString()) {
const newUrl = new URL(location.toString())
newUrl.searchParams.delete('flash')
window.history.replaceState(null, '', newUrl)
}
}, [location])
switch (message) {
case 'paused':
return (
<Notification
type="success"
content={
<Trans
i18nKey="your_subscription_will_pause_on_short"
values={{
pauseDate: subscription.payment.nextPaymentDueAt,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
}
/>
)
case 'unpaused':
return (
<Notification
type="success"
content={t('you_unpaused_your_subscription')}
/>
)
default:
return <></>
}
}

View File

@@ -0,0 +1,95 @@
import { useTranslation, Trans } from 'react-i18next'
import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
import { CancelSubscriptionButton } from './cancel-subscription-button'
import { CancelSubscription } from './cancel-plan/cancel-subscription'
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'
import PauseSubscriptionModal from '../../pause-modal'
import { ConfirmUnpauseSubscriptionModal } from './confirm-unpause-modal'
export function PausedSubscription({
subscription,
}: {
subscription: PaidSubscription
}) {
const { t } = useTranslation()
const {
recurlyLoadError,
setModalIdShown,
showCancellation,
getFormattedRenewalDate,
} = useSubscriptionDashboardContext()
if (showCancellation) return <CancelSubscription />
const handleUnpauseClick = async () => {
setModalIdShown('unpause-subscription')
}
return (
<>
<p>
<Trans
i18nKey="youve_paused_your_subscription"
values={{
planName: subscription.plan.name,
reactivationDate: getFormattedRenewalDate(),
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
</p>
<p>{t('until_then_you_can_still')}</p>
<ul>
<li>{t('access_edit_your_projects')}</li>
<li>{t('continue_using_free_features')}</li>
</ul>
<p>{t('well_be_here_when_youre_ready')}</p>
<p>
<OLButton variant="primary" onClick={handleUnpauseClick}>
{t('unpause_subscription')}
</OLButton>
{!recurlyLoadError && (
<>
{' '}
<CancelSubscriptionButton />
</>
)}
</p>
<p className="d-inline-flex flex-wrap gap-1">
<a
href={subscription.payment.billingDetailsLink}
target="_blank"
rel="noreferrer noopener"
className="btn btn-secondary-info btn-secondary"
>
{t('update_your_billing_details')}
</a>{' '}
<a
href={subscription.payment.accountManagementLink}
target="_blank"
rel="noreferrer noopener"
className="btn btn-secondary-info btn-secondary"
>
{t('view_your_invoices')}
</a>
</p>
<ChangePlanModal />
<ConfirmChangePlanModal />
<KeepCurrentPlanModal />
<ChangeToGroupModal />
<PauseSubscriptionModal />
<ConfirmUnpauseSubscriptionModal />
</>
)
}

View File

@@ -0,0 +1,27 @@
import { Trans } from 'react-i18next'
export function PendingAdditionalLicenses({
additionalLicenses,
totalLicenses,
}: {
additionalLicenses: number
totalLicenses: number
}) {
return (
<Trans
i18nKey="additional_licenses"
values={{
additionalLicenses,
totalLicenses,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
)
}

View File

@@ -0,0 +1,83 @@
import { Trans } from 'react-i18next'
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
import { PendingPaymentProviderPlan } from '../../../../../../../../types/subscription/plan'
import { AI_ADD_ON_CODE, ADD_ON_NAME } from '../../../../data/add-on-codes'
export function PendingPlanChange({
subscription,
}: {
subscription: PaidSubscription
}) {
if (!subscription.pendingPlan) return null
const pendingPlan = subscription.pendingPlan as PendingPaymentProviderPlan
const hasAiAddon = subscription.addOns?.some(
addOn => addOn.addOnCode === AI_ADD_ON_CODE
)
const pendingAiAddonCancellation =
hasAiAddon &&
!pendingPlan.addOns?.some(addOn => addOn.code === AI_ADD_ON_CODE)
const pendingAdditionalLicenses =
(subscription.payment.pendingAdditionalLicenses &&
subscription.payment.pendingAdditionalLicenses > 0) ||
subscription.payment.additionalLicenses > 0
return (
<>
{subscription.pendingPlan.name !== subscription.plan.name && (
<Trans
i18nKey="your_plan_is_changing_at_term_end"
values={{
pendingPlanName: subscription.pendingPlan.name,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
)}
{pendingAdditionalLicenses && (
<>
{' '}
<Trans
i18nKey="pending_additional_licenses"
values={{
pendingAdditionalLicenses:
subscription.payment.pendingAdditionalLicenses,
pendingTotalLicenses: subscription.payment.pendingTotalLicenses,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
</>
)}
{pendingAiAddonCancellation && (
<>
{' '}
<Trans
i18nKey="pending_addon_cancellation"
values={{
addOnName: ADD_ON_NAME,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={{ strong: <strong /> }}
/>
</>
)}
</>
)
}

View File

@@ -0,0 +1,50 @@
import { Trans } from 'react-i18next'
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
type SubscriptionRemainderProps = {
subscription: PaidSubscription
hideTime?: boolean
}
function SubscriptionRemainder({
subscription,
hideTime,
}: SubscriptionRemainderProps) {
const stillInATrial =
subscription.payment.trialEndsAtFormatted &&
subscription.payment.trialEndsAt &&
new Date(subscription.payment.trialEndsAt).getTime() > Date.now()
const terminationDate = hideTime
? subscription.payment.nextPaymentDueDate
: subscription.payment.nextPaymentDueAt
return stillInATrial ? (
<Trans
i18nKey="subscription_will_remain_active_until_end_of_trial_period_x"
values={{
terminationDate,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
) : (
<Trans
i18nKey="subscription_will_remain_active_until_end_of_billing_period_x"
values={{
terminationDate,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
)
}
export default SubscriptionRemainder

View File

@@ -0,0 +1,26 @@
import { Trans } from 'react-i18next'
type TrialEndingProps = {
trialEndsAtFormatted: string
className?: string
}
export function TrialEnding({
trialEndsAtFormatted,
className,
}: TrialEndingProps) {
return (
<p className={className}>
<Trans
i18nKey="youre_on_free_trial_which_ends_on"
values={{ date: trialEndsAtFormatted }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
</p>
)
}

View File

@@ -0,0 +1,56 @@
import { useTranslation, Trans } from 'react-i18next'
import { PaidSubscription } from '../../../../../../../types/subscription/dashboard/subscription'
import ReactivateSubscription from '../reactivate-subscription'
import OLButton from '@/features/ui/components/ol/ol-button'
export function CanceledSubscription({
subscription,
}: {
subscription: PaidSubscription
}) {
const { t } = useTranslation()
return (
<>
<p>
<Trans
i18nKey="currently_subscribed_to_plan"
values={{
planName: subscription.plan.name,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
</p>
<p>
<Trans
i18nKey="subscription_canceled_and_terminate_on_x"
values={{
terminateDate: subscription.payment.nextPaymentDueAt,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
</p>
<p>
<OLButton
href={subscription.payment.accountManagementLink}
target="_blank"
variant="secondary"
rel="noopener noreferrer"
>
{t('view_your_invoices')}
</OLButton>
</p>
<ReactivateSubscription />
</>
)
}

View File

@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next'
import { PaidSubscription } from '../../../../../../../types/subscription/dashboard/subscription'
import OLButton from '@/features/ui/components/ol/ol-button'
export function ExpiredSubscription({
subscription,
}: {
subscription: PaidSubscription
}) {
const { t } = useTranslation()
return (
<>
<p>{t('your_subscription_has_expired')}</p>
<p>
<OLButton
href={subscription.payment.accountManagementLink}
target="_blank"
rel="noreferrer noopener"
variant="secondary"
className="me-1"
>
{t('view_your_invoices')}
</OLButton>
<OLButton href="/user/subscription/plans" variant="primary">
{t('create_new_subscription')}
</OLButton>
</p>
</>
)
}

View File

@@ -0,0 +1,63 @@
import { useTranslation } from 'react-i18next'
import ContactSupport from './contact-support-for-custom-subscription'
import GroupSubscriptionMemberships from './group-subscription-memberships'
import InstitutionMemberships from './institution-memberships'
import FreePlan from './free-plan'
import ManagedPublishers from './managed-publishers'
import PersonalSubscription from './personal-subscription'
import ManagedGroupSubscriptions from './managed-group-subscriptions'
import ManagedInstitutions from './managed-institutions'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import getMeta from '../../../../utils/meta'
import PremiumFeaturesLink from './premium-features-link'
import OLPageContentCard from '@/features/ui/components/ol/ol-page-content-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()
const {
hasDisplayedSubscription,
hasSubscription,
hasValidActiveSubscription,
} = useSubscriptionDashboardContext()
const fromPlansPage = getMeta('ol-fromPlansPage')
return (
<div className="container">
<OLRow>
<OLCol lg={{ span: 8, offset: 2 }}>
{fromPlansPage && (
<OLNotification
className="mb-4"
aria-live="polite"
content={t('you_already_have_a_subscription')}
type="warning"
/>
)}
<OLPageContentCard>
<div className="page-header">
<h1>{t('your_subscription')}</h1>
</div>
<div>
<PersonalSubscription />
<ManagedGroupSubscriptions />
<ManagedInstitutions />
<ManagedPublishers />
<GroupSubscriptionMemberships />
<InstitutionMemberships />
{hasValidActiveSubscription && <PremiumFeaturesLink />}
{!hasDisplayedSubscription &&
(hasSubscription ? <ContactSupport /> : <FreePlan />)}
</div>
</OLPageContentCard>
</OLCol>
</OLRow>
</div>
)
}
export default SubscriptionDashboard

View File

@@ -0,0 +1,24 @@
import getMeta from '@/utils/meta'
import { useTranslation } from 'react-i18next'
export default function AcceptedInvite() {
const { t } = useTranslation()
const inviterName = getMeta('ol-inviterName')
const groupSSOActive = getMeta('ol-groupSSOActive')
const subscriptionId = getMeta('ol-subscriptionId')
const doneLink = groupSSOActive
? `/subscription/${subscriptionId}/sso_enrollment`
: '/project'
return (
<div className="text-center">
<p>{t('joined_team', { inviterName })}</p>
<p>
<a href={doneLink} className="btn btn-primary">
{t('done')}
</a>
</p>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
import getMeta from '@/utils/meta'
import HasIndividualRecurlySubscription from './has-individual-recurly-subscription'
import { useEffect, useState } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import ManagedUserCannotJoin from './managed-user-cannot-join'
import Notification from '@/shared/components/notification'
import JoinGroup from './join-group'
import AcceptedInvite from './accepted-invite'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLPageContentCard from '@/features/ui/components/ol/ol-page-content-card'
export type InviteViewTypes =
| 'invite'
| 'invite-accepted'
| 'cancel-personal-subscription'
| 'managed-user-cannot-join'
| undefined
function GroupInviteViews() {
const hasIndividualRecurlySubscription = getMeta(
'ol-hasIndividualRecurlySubscription'
)
const cannotJoinSubscription = getMeta('ol-cannot-join-subscription')
useEffect(() => {
if (cannotJoinSubscription) {
setView('managed-user-cannot-join')
} else if (hasIndividualRecurlySubscription) {
setView('cancel-personal-subscription')
} else {
setView('invite')
}
}, [cannotJoinSubscription, hasIndividualRecurlySubscription])
const [view, setView] = useState<InviteViewTypes>(undefined)
if (!view) {
return null
}
if (view === 'managed-user-cannot-join') {
return <ManagedUserCannotJoin />
} else if (view === 'cancel-personal-subscription') {
return <HasIndividualRecurlySubscription setView={setView} />
} else if (view === 'invite') {
return <JoinGroup setView={setView} />
} else if (view === 'invite-accepted') {
return <AcceptedInvite />
}
return null
}
export default function GroupInvite() {
const inviterName = getMeta('ol-inviterName')
const expired = getMeta('ol-expired')
const { isReady } = useWaitForI18n()
const { t } = useTranslation()
if (!isReady) {
return null
}
return (
<div className="container" id="main-content">
{expired && (
<OLRow>
<OLCol lg={{ span: 8, offset: 2 }}>
<Notification type="error" content={t('email_link_expired')} />
</OLCol>
</OLRow>
)}
<OLRow className="row row-spaced">
<OLCol lg={{ span: 8, offset: 2 }}>
<OLPageContentCard>
<div className="page-header">
<h1 className="text-center">
<Trans
i18nKey="invited_to_group"
values={{ inviterName }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={
/* eslint-disable-next-line react/jsx-key */
[<span className="team-invite-name" />]
}
/>
</h1>
</div>
<GroupInviteViews />
</OLPageContentCard>
</OLCol>
</OLRow>
</div>
)
}

View File

@@ -0,0 +1,69 @@
import { FetchError, postJSON } from '@/infrastructure/fetch-json'
import Notification from '@/shared/components/notification'
import useAsync from '@/shared/hooks/use-async'
import { debugConsole } from '@/utils/debugging'
import getMeta from '@/utils/meta'
import { Dispatch, SetStateAction, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { InviteViewTypes } from './group-invite'
import OLButton from '@/features/ui/components/ol/ol-button'
export default function HasIndividualRecurlySubscription({
setView,
}: {
setView: Dispatch<SetStateAction<InviteViewTypes>>
}) {
const { t } = useTranslation()
const {
runAsync,
isLoading: isCancelling,
isError,
} = useAsync<never, FetchError>()
const cancelPersonalSubscription = useCallback(() => {
runAsync(
postJSON('/user/subscription/cancel', {
body: {
_csrf: getMeta('ol-csrfToken'),
},
})
)
.then(() => {
setView('invite')
})
.catch(debugConsole.error)
}, [runAsync, setView])
return (
<>
{isError && (
<Notification
type="error"
content={t('something_went_wrong_canceling_your_subscription')}
className="my-3"
/>
)}
<div className="text-center">
<p>{t('cancel_personal_subscription_first')}</p>
<p>
<OLButton
variant="secondary"
disabled={isCancelling}
onClick={() => setView('invite')}
>
{t('not_now')}
</OLButton>
&nbsp;&nbsp;
<OLButton
variant="primary"
disabled={isCancelling}
onClick={() => cancelPersonalSubscription()}
>
{t('cancel_your_subscription')}
</OLButton>
</p>
</div>
</>
)
}

View File

@@ -0,0 +1,74 @@
import { Dispatch, SetStateAction, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { InviteViewTypes } from './group-invite'
import getMeta from '@/utils/meta'
import { FetchError, putJSON } from '@/infrastructure/fetch-json'
import useAsync from '@/shared/hooks/use-async'
import classNames from 'classnames'
import { debugConsole } from '@/utils/debugging'
import Notification from '@/shared/components/notification'
import OLButton from '@/features/ui/components/ol/ol-button'
export default function JoinGroup({
setView,
}: {
setView: Dispatch<SetStateAction<InviteViewTypes>>
}) {
const { t } = useTranslation()
const expired = getMeta('ol-expired')
const inviteToken = getMeta('ol-inviteToken')
const {
runAsync,
isLoading: isJoining,
isError,
} = useAsync<never, FetchError>()
const notNowBtnClasses = classNames(
'btn',
'btn-secondary',
isJoining ? 'disabled' : ''
)
const joinTeam = useCallback(() => {
runAsync(putJSON(`/subscription/invites/${inviteToken}`))
.then(() => {
setView('invite-accepted')
})
.catch(debugConsole.error)
}, [inviteToken, runAsync, setView])
if (!inviteToken) {
return null
}
return (
<>
{isError && (
<Notification
type="error"
content={t('generic_something_went_wrong')}
className="my-3"
/>
)}
<div className="text-center">
<p>{t('join_team_explanation')}</p>
{!expired && (
<p>
<a className={notNowBtnClasses} href="/project">
{t('not_now')}
</a>
&nbsp;&nbsp;
<OLButton
variant="primary"
onClick={() => joinTeam()}
disabled={isJoining}
>
{t('accept_invitation')}
</OLButton>
</p>
)}
</div>
</>
)
}

View File

@@ -0,0 +1,31 @@
import { Trans, useTranslation } from 'react-i18next'
import Notification from '@/shared/components/notification'
import getMeta from '@/utils/meta'
export default function ManagedUserCannotJoin() {
const { t } = useTranslation()
const currentManagedUserAdminEmail = getMeta(
'ol-currentManagedUserAdminEmail'
)
return (
<Notification
type="info"
title={t('you_cant_join_this_group_subscription')}
content={
<p>
<Trans
i18nKey="your_account_is_managed_by_admin_cant_join_additional_group"
values={{ admin: currentManagedUserAdminEmail }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
<a href="/learn/how-to/Understanding_Managed_Overleaf_Accounts" />,
]}
/>
</p>
}
/>
)
}

View File

@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next'
import type { TeamInvite } from '../../../../../../types/team-invite'
type GroupInvitesItemFooterProps = {
teamInvite: TeamInvite
}
export default function GroupInvitesItemFooter({
teamInvite,
}: GroupInvitesItemFooterProps) {
const { t } = useTranslation()
return (
<div>
<p data-cy="group-invites-item-footer-text">
{t('join_team_explanation')}
</p>
<div data-cy="group-invites-item-footer-link">
<a
className="btn btn-primary"
href={`/subscription/invites/${teamInvite.token}`}
>
{t('view_invitation')}
</a>
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
import { Trans } from 'react-i18next'
import GroupInvitesItemFooter from './group-invites-item-footer'
import type { TeamInvite } from '../../../../../../types/team-invite'
import OLPageContentCard from '@/features/ui/components/ol/ol-page-content-card'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
type GroupInvitesItemProps = {
teamInvite: TeamInvite
}
export default function GroupInvitesItem({
teamInvite,
}: GroupInvitesItemProps) {
return (
<OLRow className="row-spaced">
<OLCol lg={{ span: 8, offset: 2 }} className="text-center">
<OLPageContentCard>
<div className="page-header">
<h2>
<Trans
i18nKey="invited_to_group"
values={{ inviterName: teamInvite.inviterName }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={
/* eslint-disable-next-line react/jsx-key */
[<span className="team-invite-name" />]
}
/>
</h2>
</div>
<GroupInvitesItemFooter teamInvite={teamInvite} />
</OLPageContentCard>
</OLCol>
</OLRow>
)
}

View File

@@ -0,0 +1,14 @@
import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n'
import GroupInvites from './group-invites'
function GroupInvitesRoot() {
const { isReady } = useWaitForI18n()
if (!isReady) {
return null
}
return <GroupInvites />
}
export default GroupInvitesRoot

View File

@@ -0,0 +1,34 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import { useLocation } from '@/shared/hooks/use-location'
import GroupInvitesItem from './group-invites-item'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
function GroupInvites() {
const { t } = useTranslation()
const teamInvites = getMeta('ol-teamInvites')
const location = useLocation()
useEffect(() => {
if (teamInvites.length === 0) {
location.assign('/project')
}
}, [teamInvites, location])
return (
<div className="container">
<OLRow>
<OLCol lg={{ span: 8, offset: 2 }}>
<h1>{t('group_invitations')}</h1>
</OLCol>
</OLRow>
{teamInvites.map(teamInvite => (
<GroupInvitesItem teamInvite={teamInvite} key={teamInvite._id} />
))}
</div>
)
}
export default GroupInvites

View File

@@ -0,0 +1,12 @@
import { JSXElementConstructor } from 'react'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
const [inviteManagedModule] = importOverleafModules(
'managedGroupEnrollmentInvite'
)
const InviteManaged: JSXElementConstructor<Record<string, never>> =
inviteManagedModule?.import.default
export default function InviteManagedRoot() {
return <InviteManaged />
}

View File

@@ -0,0 +1,11 @@
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
import GroupInvite from './group-invite/group-invite'
export default function InviteRoot() {
const { isReady } = useWaitForI18n()
if (!isReady) {
return null
}
return <GroupInvite />
}

View File

@@ -0,0 +1,250 @@
import { useCallback } from 'react'
import moment from 'moment'
import { useTranslation, Trans } from 'react-i18next'
import {
SubscriptionChangePreview,
AddOnPurchase,
PremiumSubscriptionChange,
} from '../../../../../../types/subscription/subscription-change-preview'
import getMeta from '@/utils/meta'
import { formatCurrency } from '@/shared/utils/currency'
import useAsync from '@/shared/hooks/use-async'
import { useLocation } from '@/shared/hooks/use-location'
import { debugConsole } from '@/utils/debugging'
import { postJSON } from '@/infrastructure/fetch-json'
import Notification from '@/shared/components/notification'
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 OLButton from '@/features/ui/components/ol/ol-button'
import { subscriptionUpdateUrl } from '@/features/subscription/data/subscription-url'
import * as eventTracking from '@/infrastructure/event-tracking'
import sparkleText from '@/shared/svgs/ai-sparkle-text.svg'
function PreviewSubscriptionChange() {
const preview = getMeta(
'ol-subscriptionChangePreview'
) as SubscriptionChangePreview
const { t } = useTranslation()
const payNowTask = useAsync()
const location = useLocation()
const handlePayNowClick = useCallback(() => {
eventTracking.sendMB('assistant-add-on-purchase')
payNowTask
.runAsync(payNow(preview))
.then(() => {
location.replace('/user/subscription/thank-you')
})
.catch(debugConsole.error)
}, [location, payNowTask, preview])
const aiAddOnChange =
preview.change.type === 'add-on-purchase' &&
preview.change.addOn.code === 'assistant'
// the driver of the change, which we can display as the immediate charge
const changeName =
preview.change.type === 'add-on-purchase'
? (preview.change as AddOnPurchase).addOn.name
: (preview.change as PremiumSubscriptionChange).plan.name
return (
<div className="container">
<OLRow>
<OLCol md={{ offset: 2, span: 8 }}>
<OLCard className="p-3">
{preview.change.type === 'add-on-purchase' ? (
<h1>
{t('add_add_on_to_your_plan', {
addOnName: preview.change.addOn.name,
})}
</h1>
) : preview.change.type === 'premium-subscription' ? (
<h1>
{t('subscribe_to_plan', { planName: preview.change.plan.name })}
</h1>
) : null}
{payNowTask.isError && (
<Notification
type="error"
aria-live="polite"
content={
<>
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
{t('generic_if_problem_continues_contact_us')}.
</>
}
/>
)}
{aiAddOnChange && (
<div>
<Trans
i18nKey="add_error_assist_to_your_projects"
components={{
sparkle: (
<img
alt="sparkle"
className="ai-error-assistant-sparkle"
src={sparkleText}
aria-hidden="true"
key="sparkle"
/>
),
}}
/>
</div>
)}
<OLCard className="payment-summary-card mt-5">
<h3>{t('due_today')}:</h3>
<OLRow>
<OLCol xs={9}>{changeName}</OLCol>
<OLCol xs={3} className="text-end">
<strong>
{formatCurrency(
preview.immediateCharge.subtotal,
preview.currency
)}
</strong>
</OLCol>
</OLRow>
{preview.immediateCharge.tax > 0 && (
<OLRow className="mt-1">
<OLCol xs={9}>
{t('vat')} {preview.nextInvoice.tax.rate * 100}%
</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(
preview.immediateCharge.tax,
preview.currency
)}
</OLCol>
</OLRow>
)}
<OLRow className="mt-1">
<OLCol xs={9}>{t('total_today')}</OLCol>
<OLCol xs={3} className="text-end">
<strong>
{formatCurrency(
preview.immediateCharge.total,
preview.currency
)}
</strong>
</OLCol>
</OLRow>
</OLCard>
<div className="mt-5">
<Trans
i18nKey="this_total_reflects_the_amount_due_until"
values={{ date: moment(preview.nextInvoice.date).format('LL') }}
components={{ strong: <strong /> }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>{' '}
<Trans
i18nKey="we_will_use_your_existing_payment_method"
values={{ paymentMethod: preview.paymentMethod }}
components={{ strong: <strong /> }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</div>
<div className="mt-5">
<OLButton
variant="primary"
size="lg"
onClick={handlePayNowClick}
disabled={payNowTask.isLoading || payNowTask.isSuccess}
>
{t('pay_now')}
</OLButton>
</div>
<OLCard className="payment-summary-card mt-5">
<h3>{t('future_payments')}:</h3>
<OLRow className="mt-1">
<OLCol xs={9}>{preview.nextInvoice.plan.name}</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(
preview.nextInvoice.plan.amount,
preview.currency
)}
</OLCol>
</OLRow>
{preview.nextInvoice.addOns.map(addOn => (
<OLRow className="mt-1" key={addOn.code}>
<OLCol xs={9}>
{addOn.name}
{addOn.quantity > 1 ? ` ×${addOn.quantity}` : ''}
</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(addOn.amount, preview.currency)}
</OLCol>
</OLRow>
))}
{preview.nextInvoice.tax.rate > 0 && (
<OLRow className="mt-1">
<OLCol xs={9}>
{t('vat')} {preview.nextInvoice.tax.rate * 100}%
</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(
preview.nextInvoice.tax.amount,
preview.currency
)}
</OLCol>
</OLRow>
)}
<OLRow className="mt-1">
<OLCol xs={9}>
{preview.nextPlan.annual
? t('total_per_year')
: t('total_per_month')}
</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(preview.nextInvoice.total, preview.currency)}
</OLCol>
</OLRow>
</OLCard>
<div className="mt-5">
<Trans
i18nKey="the_next_payment_will_be_collected_on"
values={{ date: moment(preview.nextInvoice.date).format('LL') }}
components={{ strong: <strong /> }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</div>
</OLCard>
</OLCol>
</OLRow>
</div>
)
}
async function payNow(preview: SubscriptionChangePreview) {
if (preview.change.type === 'add-on-purchase') {
await postJSON(`/user/subscription/addon/${preview.change.addOn.code}/add`)
} else if (preview.change.type === 'premium-subscription') {
await postJSON(subscriptionUpdateUrl, {
body: { plan_code: preview.change.plan.code },
})
} else {
throw new Error(
`Unknown subscription change preview type: ${preview.change}`
)
}
}
export default PreviewSubscriptionChange

View File

@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next'
import { PaidSubscription } from '../../../../../../types/subscription/dashboard/subscription'
type PriceExceptionsProps = {
subscription: PaidSubscription
}
export function PriceExceptions({ subscription }: PriceExceptionsProps) {
const { t } = useTranslation()
const { activeCoupons } = subscription.payment
return (
<>
<p>
<i>* {t('subject_to_additional_vat')}</i>
</p>
{activeCoupons.length > 0 && (
<>
<i>* {t('coupons_not_included')}:</i>
<ul>
{activeCoupons.map(coupon => (
<li key={coupon.code}>
<i>{coupon.description || coupon.name}</i>
</li>
))}
</ul>
</>
)}
</>
)
}

View File

@@ -0,0 +1,22 @@
import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n'
import { SubscriptionDashboardProvider } from '../../context/subscription-dashboard-context'
import SuccessfulSubscription from './successful-subscription'
import { SplitTestProvider } from '@/shared/context/split-test-context'
function Root() {
const { isReady } = useWaitForI18n()
if (!isReady) {
return null
}
return (
<SplitTestProvider>
<SubscriptionDashboardProvider>
<SuccessfulSubscription />
</SubscriptionDashboardProvider>
</SplitTestProvider>
)
}
export default Root

View File

@@ -0,0 +1,166 @@
import { useTranslation, Trans } from 'react-i18next'
import { PriceExceptions } from '../shared/price-exceptions'
import PremiumFeaturesLink from '../dashboard/premium-features-link'
import getMeta from '../../../../utils/meta'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLPageContentCard from '@/features/ui/components/ol/ol-page-content-card'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import {
AI_ADD_ON_CODE,
ADD_ON_NAME,
isStandaloneAiPlanCode,
} from '../../data/add-on-codes'
import { PaidSubscription } from '../../../../../../types/subscription/dashboard/subscription'
function SuccessfulSubscription() {
const { t } = useTranslation()
const { personalSubscription: subscription } =
useSubscriptionDashboardContext()
const postCheckoutRedirect = getMeta('ol-postCheckoutRedirect')
const { appName, adminEmail } = getMeta('ol-ExposedSettings')
if (!subscription || !('payment' in subscription)) return null
const onAiStandalonePlan = isStandaloneAiPlanCode(subscription.planCode)
return (
<div className="container">
<OLRow>
<OLCol lg={{ span: 8, offset: 2 }}>
<OLPageContentCard>
<div className="page-header">
<h2>{t('thanks_for_subscribing')}</h2>
</div>
<OLNotification
type="success"
content={
<>
{subscription.payment.trialEndsAt && (
<>
<p>
<Trans
i18nKey="next_payment_of_x_collectected_on_y"
values={{
paymentAmmount: subscription.payment.displayPrice,
collectionDate:
subscription.payment.nextPaymentDueAt,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[<strong />, <strong />]} // eslint-disable-line react/jsx-key
/>
</p>
<PriceExceptions subscription={subscription} />
</>
)}
<p>
{t('to_modify_your_subscription_go_to')}&nbsp;
<a href="/user/subscription" rel="noopener noreferrer">
{t('manage_subscription')}.
</a>
</p>
</>
}
/>
{subscription.groupPlan && (
<p>
<a
href={`/manage/groups/${subscription._id}/members`}
className="btn btn-primary btn-large"
>
{t('add_your_first_group_member_now')}
</a>
</p>
)}
<ThankYouSection
subscription={subscription}
onAiStandalonePlan={onAiStandalonePlan}
/>
{!onAiStandalonePlan && <PremiumFeaturesLink />}
<p>
{t('need_anything_contact_us_at')}&nbsp;
<a href={`mailto:${adminEmail}`} rel="noopener noreferrer">
{adminEmail}
</a>
.
</p>
{!onAiStandalonePlan && (
<p>
<Trans
i18nKey="help_improve_overleaf_fill_out_this_survey"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a
href="https://forms.gle/CdLNX9m6NLxkv1yr5"
target="_blank"
rel="noopener noreferrer"
/>,
]}
/>
</p>
)}
<p>
{t('regards')},
<br />
The {appName} Team
</p>
<p>
<a
className="btn btn-primary"
href={postCheckoutRedirect || '/project'}
rel="noopener noreferrer"
>
&lt; {t('back_to_your_projects')}
</a>
</p>
</OLPageContentCard>
</OLCol>
</OLRow>
</div>
)
}
function ThankYouSection({
subscription,
onAiStandalonePlan,
}: {
subscription: PaidSubscription
onAiStandalonePlan: boolean
}) {
const { t } = useTranslation()
const hasAiAddon = subscription?.addOns?.some(
addOn => addOn.addOnCode === AI_ADD_ON_CODE
)
if (onAiStandalonePlan) {
return (
<p>
{t('thanks_for_subscribing_to_the_add_on', {
addOnName: ADD_ON_NAME,
})}
</p>
)
}
if (hasAiAddon) {
return (
<p>
{t('thanks_for_subscribing_to_plan_with_add_on', {
planName: subscription.plan.name,
addOnName: ADD_ON_NAME,
})}
</p>
)
}
return (
<p>
{t('thanks_for_subscribing_you_help_sl', {
planName: subscription.plan.name,
})}
</p>
)
}
export default SuccessfulSubscription