first commit
This commit is contained in:
@@ -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')}
|
||||
<a href="/user/subscription" rel="noopener noreferrer">
|
||||
{t('manage_subscription')}.
|
||||
</a>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
<p>
|
||||
<a
|
||||
className="btn btn-primary"
|
||||
href="/project"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
< {t('back_to_your_projects')}
|
||||
</a>
|
||||
</p>
|
||||
</OLPageContentCard>
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Canceled
|
@@ -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
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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')}.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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}`}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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}`}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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 <></>
|
||||
}
|
||||
}
|
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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 />,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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 /> }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
|
||||
<OLButton
|
||||
variant="primary"
|
||||
disabled={isCancelling}
|
||||
onClick={() => cancelPersonalSubscription()}
|
||||
>
|
||||
{t('cancel_your_subscription')}
|
||||
</OLButton>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={() => joinTeam()}
|
||||
disabled={isJoining}
|
||||
>
|
||||
{t('accept_invitation')}
|
||||
</OLButton>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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
|
@@ -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 />
|
||||
}
|
@@ -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 />
|
||||
}
|
@@ -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
|
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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')}
|
||||
<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')}
|
||||
<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"
|
||||
>
|
||||
< {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
|
Reference in New Issue
Block a user