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
|
||||
@@ -0,0 +1,358 @@
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
CustomSubscription,
|
||||
ManagedGroupSubscription,
|
||||
MemberGroupSubscription,
|
||||
PaidSubscription,
|
||||
} from '../../../../../types/subscription/dashboard/subscription'
|
||||
import {
|
||||
Plan,
|
||||
PriceForDisplayData,
|
||||
} from '../../../../../types/subscription/plan'
|
||||
import { Institution } from '../../../../../types/institution'
|
||||
import getMeta from '../../../utils/meta'
|
||||
import {
|
||||
loadDisplayPriceWithTaxPromise,
|
||||
loadGroupDisplayPriceWithTaxPromise,
|
||||
} from '../util/recurly-pricing'
|
||||
import { isRecurlyLoaded } from '../util/is-recurly-loaded'
|
||||
import { SubscriptionDashModalIds } from '../../../../../types/subscription/dashboard/modal-ids'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { formatCurrency } from '@/shared/utils/currency'
|
||||
import { ManagedInstitution } from '../../../../../types/subscription/dashboard/managed-institution'
|
||||
import { Publisher } from '../../../../../types/subscription/dashboard/publisher'
|
||||
import { formatTime } from '@/features/utils/format-date'
|
||||
|
||||
type SubscriptionDashboardContextValue = {
|
||||
groupPlanToChangeToCode: string
|
||||
groupPlanToChangeToSize: string
|
||||
groupPlanToChangeToUsage: string
|
||||
groupPlanToChangeToPrice?: PriceForDisplayData
|
||||
groupPlanToChangeToPriceError?: boolean
|
||||
handleCloseModal: () => void
|
||||
handleOpenModal: (
|
||||
modalIdToOpen: SubscriptionDashModalIds,
|
||||
planCode?: string
|
||||
) => void
|
||||
hasDisplayedSubscription: boolean
|
||||
hasValidActiveSubscription: boolean
|
||||
institutionMemberships?: Institution[]
|
||||
managedGroupSubscriptions: ManagedGroupSubscription[]
|
||||
memberGroupSubscriptions: MemberGroupSubscription[]
|
||||
managedInstitutions: ManagedInstitution[]
|
||||
managedPublishers: Publisher[]
|
||||
updateManagedInstitution: (institution: ManagedInstitution) => void
|
||||
modalIdShown?: SubscriptionDashModalIds
|
||||
personalSubscription?: PaidSubscription | CustomSubscription
|
||||
hasSubscription: boolean
|
||||
plans: Plan[]
|
||||
planCodeToChangeTo?: string
|
||||
queryingGroupPlanToChangeToPrice: boolean
|
||||
queryingIndividualPlansData: boolean
|
||||
recurlyLoadError: boolean
|
||||
setGroupPlanToChangeToCode: React.Dispatch<React.SetStateAction<string>>
|
||||
setGroupPlanToChangeToSize: React.Dispatch<React.SetStateAction<string>>
|
||||
setGroupPlanToChangeToUsage: React.Dispatch<React.SetStateAction<string>>
|
||||
setModalIdShown: React.Dispatch<
|
||||
React.SetStateAction<SubscriptionDashModalIds | undefined>
|
||||
>
|
||||
setPlanCodeToChangeTo: React.Dispatch<
|
||||
React.SetStateAction<string | undefined>
|
||||
>
|
||||
setRecurlyLoadError: React.Dispatch<React.SetStateAction<boolean>>
|
||||
showCancellation: boolean
|
||||
setShowCancellation: React.Dispatch<React.SetStateAction<boolean>>
|
||||
leavingGroupId?: string
|
||||
setLeavingGroupId: React.Dispatch<React.SetStateAction<string | undefined>>
|
||||
userCanExtendTrial: boolean
|
||||
getFormattedRenewalDate: () => string
|
||||
}
|
||||
|
||||
export const SubscriptionDashboardContext = createContext<
|
||||
SubscriptionDashboardContextValue | undefined
|
||||
>(undefined)
|
||||
|
||||
export function SubscriptionDashboardProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
const { i18n } = useTranslation()
|
||||
const [modalIdShown, setModalIdShown] = useState<
|
||||
SubscriptionDashModalIds | undefined
|
||||
>()
|
||||
const [recurlyLoadError, setRecurlyLoadError] = useState(false)
|
||||
const [showCancellation, setShowCancellation] = useState(false)
|
||||
const [plans, setPlans] = useState<Plan[]>([])
|
||||
const [queryingIndividualPlansData, setQueryingIndividualPlansData] =
|
||||
useState(true)
|
||||
const [planCodeToChangeTo, setPlanCodeToChangeTo] = useState<
|
||||
string | undefined
|
||||
>()
|
||||
const [groupPlanToChangeToSize, setGroupPlanToChangeToSize] = useState('10')
|
||||
const [groupPlanToChangeToCode, setGroupPlanToChangeToCode] =
|
||||
useState('collaborator')
|
||||
const [groupPlanToChangeToUsage, setGroupPlanToChangeToUsage] =
|
||||
useState('enterprise')
|
||||
const [
|
||||
queryingGroupPlanToChangeToPrice,
|
||||
setQueryingGroupPlanToChangeToPrice,
|
||||
] = useState(false)
|
||||
const [groupPlanToChangeToPrice, setGroupPlanToChangeToPrice] =
|
||||
useState<PriceForDisplayData>()
|
||||
const [groupPlanToChangeToPriceError, setGroupPlanToChangeToPriceError] =
|
||||
useState(false)
|
||||
const [leavingGroupId, setLeavingGroupId] = useState<string | undefined>()
|
||||
|
||||
const plansWithoutDisplayPrice = getMeta('ol-plans')
|
||||
const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence')
|
||||
const personalSubscription = getMeta('ol-subscription')
|
||||
const userCanExtendTrial = getMeta('ol-userCanExtendTrial')
|
||||
const managedGroupSubscriptions = getMeta('ol-managedGroupSubscriptions')
|
||||
const memberGroupSubscriptions = getMeta('ol-memberGroupSubscriptions')
|
||||
const [managedInstitutions, setManagedInstitutions] = useState(
|
||||
getMeta('ol-managedInstitutions')
|
||||
)
|
||||
const managedPublishers = getMeta('ol-managedPublishers')
|
||||
const hasSubscription = getMeta('ol-hasSubscription')
|
||||
const recurlyApiKey = getMeta('ol-recurlyApiKey')
|
||||
|
||||
const hasDisplayedSubscription = Boolean(
|
||||
institutionMemberships?.length > 0 ||
|
||||
personalSubscription ||
|
||||
memberGroupSubscriptions?.length > 0 ||
|
||||
managedGroupSubscriptions?.length > 0 ||
|
||||
managedInstitutions?.length > 0 ||
|
||||
managedPublishers?.length > 0
|
||||
)
|
||||
|
||||
const hasValidActiveSubscription = Boolean(
|
||||
['active', 'canceled'].includes(personalSubscription?.payment?.state) ||
|
||||
institutionMemberships?.length > 0 ||
|
||||
memberGroupSubscriptions?.length > 0
|
||||
)
|
||||
|
||||
const getFormattedRenewalDate = useCallback(() => {
|
||||
if (
|
||||
!personalSubscription.payment.pausedAt ||
|
||||
!personalSubscription.payment.remainingPauseCycles
|
||||
) {
|
||||
return personalSubscription.payment.nextPaymentDueAt
|
||||
}
|
||||
const pausedDate = new Date(personalSubscription.payment.pausedAt)
|
||||
pausedDate.setMonth(
|
||||
pausedDate.getMonth() + personalSubscription.payment.remainingPauseCycles
|
||||
)
|
||||
return formatTime(pausedDate, 'MMMM Do, YYYY')
|
||||
}, [personalSubscription])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRecurlyLoaded()) {
|
||||
setRecurlyLoadError(true)
|
||||
} else if (recurlyApiKey) {
|
||||
recurly.configure(recurlyApiKey)
|
||||
}
|
||||
}, [recurlyApiKey, setRecurlyLoadError])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isRecurlyLoaded() &&
|
||||
plansWithoutDisplayPrice &&
|
||||
personalSubscription?.payment
|
||||
) {
|
||||
const { currency, taxRate } = personalSubscription.payment
|
||||
const fetchPlansDisplayPrices = async () => {
|
||||
for (const plan of plansWithoutDisplayPrice) {
|
||||
try {
|
||||
const priceData = await loadDisplayPriceWithTaxPromise(
|
||||
plan.planCode,
|
||||
currency,
|
||||
taxRate,
|
||||
i18n.language
|
||||
)
|
||||
if (priceData?.totalAsNumber !== undefined) {
|
||||
plan.displayPrice = formatCurrency(
|
||||
priceData.totalAsNumber,
|
||||
currency,
|
||||
i18n.language
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
debugConsole.error(error)
|
||||
}
|
||||
}
|
||||
setPlans(plansWithoutDisplayPrice)
|
||||
setQueryingIndividualPlansData(false)
|
||||
}
|
||||
fetchPlansDisplayPrices().catch(debugConsole.error)
|
||||
}
|
||||
}, [personalSubscription, plansWithoutDisplayPrice, i18n.language])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isRecurlyLoaded() &&
|
||||
groupPlanToChangeToCode &&
|
||||
groupPlanToChangeToSize &&
|
||||
groupPlanToChangeToUsage &&
|
||||
personalSubscription?.payment
|
||||
) {
|
||||
setQueryingGroupPlanToChangeToPrice(true)
|
||||
|
||||
const { currency, taxRate } = personalSubscription.payment
|
||||
const fetchGroupDisplayPrice = async () => {
|
||||
setGroupPlanToChangeToPriceError(false)
|
||||
let priceData
|
||||
try {
|
||||
priceData = await loadGroupDisplayPriceWithTaxPromise(
|
||||
groupPlanToChangeToCode,
|
||||
currency,
|
||||
taxRate,
|
||||
groupPlanToChangeToSize,
|
||||
groupPlanToChangeToUsage,
|
||||
i18n.language
|
||||
)
|
||||
} catch (e) {
|
||||
debugConsole.error(e)
|
||||
setGroupPlanToChangeToPriceError(true)
|
||||
}
|
||||
setQueryingGroupPlanToChangeToPrice(false)
|
||||
setGroupPlanToChangeToPrice(priceData)
|
||||
}
|
||||
fetchGroupDisplayPrice()
|
||||
}
|
||||
}, [
|
||||
groupPlanToChangeToUsage,
|
||||
groupPlanToChangeToSize,
|
||||
personalSubscription,
|
||||
groupPlanToChangeToCode,
|
||||
i18n.language,
|
||||
])
|
||||
|
||||
const updateManagedInstitution = useCallback(
|
||||
(institution: ManagedInstitution) => {
|
||||
setManagedInstitutions(institutions => {
|
||||
return [
|
||||
...(institutions || []).map(i =>
|
||||
i.v1Id === institution.v1Id ? institution : i
|
||||
),
|
||||
]
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setModalIdShown(undefined)
|
||||
setPlanCodeToChangeTo(undefined)
|
||||
}, [setModalIdShown, setPlanCodeToChangeTo])
|
||||
|
||||
const handleOpenModal = useCallback(
|
||||
(id, planCode) => {
|
||||
setModalIdShown(id)
|
||||
setPlanCodeToChangeTo(planCode)
|
||||
},
|
||||
[setModalIdShown, setPlanCodeToChangeTo]
|
||||
)
|
||||
|
||||
const value = useMemo<SubscriptionDashboardContextValue>(
|
||||
() => ({
|
||||
groupPlanToChangeToCode,
|
||||
groupPlanToChangeToPrice,
|
||||
groupPlanToChangeToPriceError,
|
||||
groupPlanToChangeToSize,
|
||||
groupPlanToChangeToUsage,
|
||||
handleCloseModal,
|
||||
handleOpenModal,
|
||||
hasDisplayedSubscription,
|
||||
hasValidActiveSubscription,
|
||||
institutionMemberships,
|
||||
managedGroupSubscriptions,
|
||||
memberGroupSubscriptions,
|
||||
managedInstitutions,
|
||||
managedPublishers,
|
||||
updateManagedInstitution,
|
||||
modalIdShown,
|
||||
personalSubscription,
|
||||
hasSubscription,
|
||||
plans,
|
||||
planCodeToChangeTo,
|
||||
queryingGroupPlanToChangeToPrice,
|
||||
queryingIndividualPlansData,
|
||||
recurlyLoadError,
|
||||
setGroupPlanToChangeToCode,
|
||||
setGroupPlanToChangeToSize,
|
||||
setGroupPlanToChangeToUsage,
|
||||
setModalIdShown,
|
||||
setPlanCodeToChangeTo,
|
||||
setRecurlyLoadError,
|
||||
showCancellation,
|
||||
setShowCancellation,
|
||||
leavingGroupId,
|
||||
setLeavingGroupId,
|
||||
userCanExtendTrial,
|
||||
getFormattedRenewalDate,
|
||||
}),
|
||||
[
|
||||
groupPlanToChangeToCode,
|
||||
groupPlanToChangeToPrice,
|
||||
groupPlanToChangeToPriceError,
|
||||
groupPlanToChangeToSize,
|
||||
groupPlanToChangeToUsage,
|
||||
handleCloseModal,
|
||||
handleOpenModal,
|
||||
hasDisplayedSubscription,
|
||||
hasValidActiveSubscription,
|
||||
institutionMemberships,
|
||||
managedGroupSubscriptions,
|
||||
memberGroupSubscriptions,
|
||||
managedInstitutions,
|
||||
managedPublishers,
|
||||
updateManagedInstitution,
|
||||
modalIdShown,
|
||||
personalSubscription,
|
||||
hasSubscription,
|
||||
plans,
|
||||
planCodeToChangeTo,
|
||||
queryingGroupPlanToChangeToPrice,
|
||||
queryingIndividualPlansData,
|
||||
recurlyLoadError,
|
||||
setGroupPlanToChangeToCode,
|
||||
setGroupPlanToChangeToSize,
|
||||
setGroupPlanToChangeToUsage,
|
||||
setModalIdShown,
|
||||
setPlanCodeToChangeTo,
|
||||
setRecurlyLoadError,
|
||||
showCancellation,
|
||||
setShowCancellation,
|
||||
leavingGroupId,
|
||||
setLeavingGroupId,
|
||||
userCanExtendTrial,
|
||||
getFormattedRenewalDate,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<SubscriptionDashboardContext.Provider value={value}>
|
||||
{children}
|
||||
</SubscriptionDashboardContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSubscriptionDashboardContext() {
|
||||
const context = useContext(SubscriptionDashboardContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'SubscriptionDashboardContext is only available inside SubscriptionDashboardProvider'
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const AI_STANDALONE_PLAN_CODE = 'assistant'
|
||||
export const AI_ADD_ON_CODE = 'assistant'
|
||||
// we dont want translations on plan or add-on names
|
||||
export const ADD_ON_NAME = "Error Assist"
|
||||
export const AI_STANDALONE_ANNUAL_PLAN_CODE = 'assistant-annual'
|
||||
|
||||
export function isStandaloneAiPlanCode(planCode: string) {
|
||||
return planCode === AI_STANDALONE_PLAN_CODE || planCode === AI_STANDALONE_ANNUAL_PLAN_CODE
|
||||
}
|
||||
278
services/web/frontend/js/features/subscription/data/countries.ts
Normal file
278
services/web/frontend/js/features/subscription/data/countries.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
// list taken from Recurly (see https://docs.recurly.com/docs/countries-provinces-and-states). Country code must exist on Recurly, so update with care
|
||||
const countries = <const>[
|
||||
{ code: 'AF', name: 'Afghanistan' },
|
||||
{ code: 'AX', name: 'Åland Islands' },
|
||||
{ code: 'AL', name: 'Albania' },
|
||||
{ code: 'DZ', name: 'Algeria' },
|
||||
{ code: 'AS', name: 'American Samoa' },
|
||||
{ code: 'AD', name: 'Andorra' },
|
||||
{ code: 'AO', name: 'Angola' },
|
||||
{ code: 'AI', name: 'Anguilla' },
|
||||
{ code: 'AQ', name: 'Antarctica' },
|
||||
{ code: 'AG', name: 'Antigua and Barbuda' },
|
||||
{ code: 'AR', name: 'Argentina' },
|
||||
{ code: 'AM', name: 'Armenia' },
|
||||
{ code: 'AW', name: 'Aruba' },
|
||||
{ code: 'AC', name: 'Ascension Island' },
|
||||
{ code: 'AU', name: 'Australia' },
|
||||
{ code: 'AT', name: 'Austria' },
|
||||
{ code: 'AZ', name: 'Azerbaijan' },
|
||||
{ code: 'BS', name: 'Bahamas' },
|
||||
{ code: 'BH', name: 'Bahrain' },
|
||||
{ code: 'BD', name: 'Bangladesh' },
|
||||
{ code: 'BB', name: 'Barbados' },
|
||||
{ code: 'BY', name: 'Belarus' },
|
||||
{ code: 'BE', name: 'Belgium' },
|
||||
{ code: 'BZ', name: 'Belize' },
|
||||
{ code: 'BJ', name: 'Benin' },
|
||||
{ code: 'BM', name: 'Bermuda' },
|
||||
{ code: 'BT', name: 'Bhutan' },
|
||||
{ code: 'BO', name: 'Bolivia' },
|
||||
{ code: 'BA', name: 'Bosnia and Herzegovina' },
|
||||
{ code: 'BW', name: 'Botswana' },
|
||||
{ code: 'BV', name: 'Bouvet Island' },
|
||||
{ code: 'BR', name: 'Brazil' },
|
||||
{ code: 'BQ', name: 'British Antarctic Territory' },
|
||||
{ code: 'IO', name: 'British Indian Ocean Territory' },
|
||||
{ code: 'VG', name: 'British Virgin Islands' },
|
||||
{ code: 'BN', name: 'Brunei' },
|
||||
{ code: 'BG', name: 'Bulgaria' },
|
||||
{ code: 'BF', name: 'Burkina Faso' },
|
||||
{ code: 'BI', name: 'Burundi' },
|
||||
{ code: 'CV', name: 'Cabo Verde' },
|
||||
{ code: 'KH', name: 'Cambodia' },
|
||||
{ code: 'CM', name: 'Cameroon' },
|
||||
{ code: 'CA', name: 'Canada' },
|
||||
{ code: 'CT', name: 'Canton and Enderbury Islands' },
|
||||
{ code: 'KY', name: 'Cayman Islands' },
|
||||
{ code: 'CF', name: 'Central African Republic' },
|
||||
{ code: 'EA', name: 'Ceuta and Melilla' },
|
||||
{ code: 'TD', name: 'Chad' },
|
||||
{ code: 'CL', name: 'Chile' },
|
||||
{ code: 'CN', name: 'China' },
|
||||
{ code: 'CX', name: 'Christmas Island' },
|
||||
{ code: 'CP', name: 'Clipperton Island' },
|
||||
{ code: 'CC', name: 'Cocos [Keeling] Islands' },
|
||||
{ code: 'CO', name: 'Colombia' },
|
||||
{ code: 'KM', name: 'Comoros' },
|
||||
{ code: 'CG', name: 'Congo - Brazzaville' },
|
||||
{ code: 'CD', name: 'Congo - Kinshasa' },
|
||||
{ code: 'CD', name: 'Congo [DRC]' },
|
||||
{ code: 'CG', name: 'Congo [Republic]' },
|
||||
{ code: 'CK', name: 'Cook Islands' },
|
||||
{ code: 'CR', name: 'Costa Rica' },
|
||||
{ code: 'CI', name: 'Côte d’Ivoire' },
|
||||
{ code: 'HR', name: 'Croatia' },
|
||||
// { code: 'CU', name: 'Cuba' }, // blocked
|
||||
{ code: 'CY', name: 'Cyprus' },
|
||||
{ code: 'CZ', name: 'Czech Republic' },
|
||||
{ code: 'DK', name: 'Denmark' },
|
||||
{ code: 'DG', name: 'Diego Garcia' },
|
||||
{ code: 'DJ', name: 'Djibouti' },
|
||||
{ code: 'DM', name: 'Dominica' },
|
||||
{ code: 'DO', name: 'Dominican Republic' },
|
||||
{ code: 'NQ', name: 'Dronning Maud Land' },
|
||||
{ code: 'TL', name: 'East Timor' },
|
||||
{ code: 'EC', name: 'Ecuador' },
|
||||
{ code: 'EG', name: 'Egypt' },
|
||||
{ code: 'SV', name: 'El Salvador' },
|
||||
{ code: 'GQ', name: 'Equatorial Guinea' },
|
||||
{ code: 'ER', name: 'Eritrea' },
|
||||
{ code: 'EE', name: 'Estonia' },
|
||||
{ code: 'ET', name: 'Ethiopia' },
|
||||
{ code: 'FK', name: 'Falkland Islands [Islas Malvinas]' },
|
||||
{ code: 'FK', name: 'Falkland Islands' },
|
||||
{ code: 'FO', name: 'Faroe Islands' },
|
||||
{ code: 'FJ', name: 'Fiji' },
|
||||
{ code: 'FI', name: 'Finland' },
|
||||
{ code: 'FR', name: 'France' },
|
||||
{ code: 'GF', name: 'French Guiana' },
|
||||
{ code: 'PF', name: 'French Polynesia' },
|
||||
{ code: 'FQ', name: 'French Southern and Antarctic Territories' },
|
||||
{ code: 'TF', name: 'French Southern Territories' },
|
||||
{ code: 'GA', name: 'Gabon' },
|
||||
{ code: 'GM', name: 'Gambia' },
|
||||
{ code: 'GE', name: 'Georgia' },
|
||||
{ code: 'DE', name: 'Germany' },
|
||||
{ code: 'GH', name: 'Ghana' },
|
||||
{ code: 'GI', name: 'Gibraltar' },
|
||||
{ code: 'GR', name: 'Greece' },
|
||||
{ code: 'GL', name: 'Greenland' },
|
||||
{ code: 'GD', name: 'Grenada' },
|
||||
{ code: 'GP', name: 'Guadeloupe' },
|
||||
{ code: 'GU', name: 'Guam' },
|
||||
{ code: 'GT', name: 'Guatemala' },
|
||||
{ code: 'GG', name: 'Guernsey' },
|
||||
{ code: 'GW', name: 'Guinea-Bissau' },
|
||||
{ code: 'GN', name: 'Guinea' },
|
||||
{ code: 'GY', name: 'Guyana' },
|
||||
{ code: 'HT', name: 'Haiti' },
|
||||
{ code: 'HM', name: 'Heard Island and McDonald Islands' },
|
||||
{ code: 'HN', name: 'Honduras' },
|
||||
{ code: 'HK', name: 'Hong Kong' },
|
||||
{ code: 'HU', name: 'Hungary' },
|
||||
{ code: 'IS', name: 'Iceland' },
|
||||
{ code: 'IN', name: 'India' },
|
||||
{ code: 'ID', name: 'Indonesia' },
|
||||
// { code: 'IR', name: 'Iran' }, // blocked
|
||||
{ code: 'IQ', name: 'Iraq' },
|
||||
{ code: 'IE', name: 'Ireland' },
|
||||
{ code: 'IM', name: 'Isle of Man' },
|
||||
{ code: 'IL', name: 'Israel' },
|
||||
{ code: 'IT', name: 'Italy' },
|
||||
{ code: 'CI', name: 'Ivory Coast' },
|
||||
{ code: 'JM', name: 'Jamaica' },
|
||||
{ code: 'JP', name: 'Japan' },
|
||||
{ code: 'JE', name: 'Jersey' },
|
||||
{ code: 'JT', name: 'Johnston Island' },
|
||||
{ code: 'JO', name: 'Jordan' },
|
||||
{ code: 'KZ', name: 'Kazakhstan' },
|
||||
{ code: 'KE', name: 'Kenya' },
|
||||
{ code: 'KI', name: 'Kiribati' },
|
||||
{ code: 'KW', name: 'Kuwait' },
|
||||
{ code: 'KG', name: 'Kyrgyzstan' },
|
||||
{ code: 'LA', name: 'Laos' },
|
||||
{ code: 'LV', name: 'Latvia' },
|
||||
{ code: 'LB', name: 'Lebanon' },
|
||||
{ code: 'LS', name: 'Lesotho' },
|
||||
{ code: 'LR', name: 'Liberia' },
|
||||
{ code: 'LY', name: 'Libya' },
|
||||
{ code: 'LI', name: 'Liechtenstein' },
|
||||
{ code: 'LT', name: 'Lithuania' },
|
||||
{ code: 'LU', name: 'Luxembourg' },
|
||||
{ code: 'MO', name: 'Macau SAR China' },
|
||||
{ code: 'MO', name: 'Macau' },
|
||||
{ code: 'MK', name: 'Macedonia [FYROM]' },
|
||||
{ code: 'MK', name: 'Macedonia' },
|
||||
{ code: 'MG', name: 'Madagascar' },
|
||||
{ code: 'MW', name: 'Malawi' },
|
||||
{ code: 'MY', name: 'Malaysia' },
|
||||
{ code: 'MV', name: 'Maldives' },
|
||||
{ code: 'ML', name: 'Mali' },
|
||||
{ code: 'MT', name: 'Malta' },
|
||||
{ code: 'MH', name: 'Marshall Islands' },
|
||||
{ code: 'MQ', name: 'Martinique' },
|
||||
{ code: 'MR', name: 'Mauritania' },
|
||||
{ code: 'MU', name: 'Mauritius' },
|
||||
{ code: 'YT', name: 'Mayotte' },
|
||||
{ code: 'FX', name: 'Metropolitan France' },
|
||||
{ code: 'MX', name: 'Mexico' },
|
||||
{ code: 'FM', name: 'Micronesia' },
|
||||
{ code: 'MI', name: 'Midway Islands' },
|
||||
{ code: 'MD', name: 'Moldova' },
|
||||
{ code: 'MC', name: 'Monaco' },
|
||||
{ code: 'MN', name: 'Mongolia' },
|
||||
{ code: 'ME', name: 'Montenegro' },
|
||||
{ code: 'MS', name: 'Montserrat' },
|
||||
{ code: 'MA', name: 'Morocco' },
|
||||
{ code: 'MZ', name: 'Mozambique' },
|
||||
{ code: 'MM', name: 'Myanmar [Burma]' },
|
||||
{ code: 'NA', name: 'Namibia' },
|
||||
{ code: 'NR', name: 'Nauru' },
|
||||
{ code: 'NP', name: 'Nepal' },
|
||||
{ code: 'AN', name: 'Netherlands Antilles' },
|
||||
{ code: 'NL', name: 'Netherlands' },
|
||||
{ code: 'NC', name: 'New Caledonia' },
|
||||
{ code: 'NZ', name: 'New Zealand' },
|
||||
{ code: 'NI', name: 'Nicaragua' },
|
||||
{ code: 'NE', name: 'Niger' },
|
||||
{ code: 'NG', name: 'Nigeria' },
|
||||
{ code: 'NU', name: 'Niue' },
|
||||
{ code: 'NF', name: 'Norfolk Island' },
|
||||
// { code: 'KP', name: 'North Korea' }, // blocked
|
||||
{ code: 'VD', name: 'North Vietnam' },
|
||||
{ code: 'MP', name: 'Northern Mariana Islands' },
|
||||
{ code: 'NO', name: 'Norway' },
|
||||
{ code: 'OM', name: 'Oman' },
|
||||
{ code: 'QO', name: 'Outlying Oceania' },
|
||||
{ code: 'PC', name: 'Pacific Islands Trust Territory' },
|
||||
{ code: 'PK', name: 'Pakistan' },
|
||||
{ code: 'PW', name: 'Palau' },
|
||||
{ code: 'PS', name: 'Palestinian Territories' },
|
||||
{ code: 'PZ', name: 'Panama Canal Zone' },
|
||||
{ code: 'PA', name: 'Panama' },
|
||||
{ code: 'PG', name: 'Papua New Guinea' },
|
||||
{ code: 'PY', name: 'Paraguay' },
|
||||
{ code: 'YD', name: "People's Democratic Republic of Yemen" },
|
||||
{ code: 'PE', name: 'Peru' },
|
||||
{ code: 'PH', name: 'Philippines' },
|
||||
{ code: 'PN', name: 'Pitcairn Islands' },
|
||||
{ code: 'PL', name: 'Poland' },
|
||||
{ code: 'PT', name: 'Portugal' },
|
||||
{ code: 'PR', name: 'Puerto Rico' },
|
||||
{ code: 'QA', name: 'Qatar' },
|
||||
{ code: 'RE', name: 'Réunion' },
|
||||
{ code: 'RO', name: 'Romania' },
|
||||
// { code: 'RU', name: 'Russia' }, // blocked
|
||||
{ code: 'RW', name: 'Rwanda' },
|
||||
{ code: 'BL', name: 'Saint Barthélemy' },
|
||||
{ code: 'SH', name: 'Saint Helena' },
|
||||
{ code: 'KN', name: 'Saint Kitts and Nevis' },
|
||||
{ code: 'LC', name: 'Saint Lucia' },
|
||||
{ code: 'MF', name: 'Saint Martin' },
|
||||
{ code: 'PM', name: 'Saint Pierre and Miquelon' },
|
||||
{ code: 'VC', name: 'Saint Vincent and the Grenadines' },
|
||||
{ code: 'WS', name: 'Samoa' },
|
||||
{ code: 'SM', name: 'San Marino' },
|
||||
{ code: 'ST', name: 'São Tomé and Príncipe' },
|
||||
{ code: 'SA', name: 'Saudi Arabia' },
|
||||
{ code: 'SN', name: 'Senegal' },
|
||||
{ code: 'CS', name: 'Serbia and Montenegro' },
|
||||
{ code: 'RS', name: 'Serbia' },
|
||||
{ code: 'SC', name: 'Seychelles' },
|
||||
{ code: 'SL', name: 'Sierra Leone' },
|
||||
{ code: 'SG', name: 'Singapore' },
|
||||
{ code: 'SK', name: 'Slovakia' },
|
||||
{ code: 'SI', name: 'Slovenia' },
|
||||
{ code: 'SB', name: 'Solomon Islands' },
|
||||
{ code: 'SO', name: 'Somalia' },
|
||||
{ code: 'ZA', name: 'South Africa' },
|
||||
{ code: 'GS', name: 'South Georgia and the South Sandwich Islands' },
|
||||
{ code: 'KR', name: 'South Korea' },
|
||||
{ code: 'ES', name: 'Spain' },
|
||||
{ code: 'LK', name: 'Sri Lanka' },
|
||||
{ code: 'SD', name: 'Sudan' },
|
||||
{ code: 'SR', name: 'Suriname' },
|
||||
{ code: 'SJ', name: 'Svalbard and Jan Mayen' },
|
||||
{ code: 'SZ', name: 'Swaziland' },
|
||||
{ code: 'SE', name: 'Sweden' },
|
||||
{ code: 'CH', name: 'Switzerland' },
|
||||
// { code: 'SY', name: 'Syria' }, // blocked
|
||||
{ code: 'TW', name: 'Taiwan' },
|
||||
{ code: 'TJ', name: 'Tajikistan' },
|
||||
{ code: 'TZ', name: 'Tanzania' },
|
||||
{ code: 'TH', name: 'Thailand' },
|
||||
{ code: 'TL', name: 'Timor-Leste' },
|
||||
{ code: 'TG', name: 'Togo' },
|
||||
{ code: 'TK', name: 'Tokelau' },
|
||||
{ code: 'TO', name: 'Tonga' },
|
||||
{ code: 'TT', name: 'Trinidad and Tobago' },
|
||||
{ code: 'TA', name: 'Tristan da Cunha' },
|
||||
{ code: 'TN', name: 'Tunisia' },
|
||||
{ code: 'TR', name: 'Turkey' },
|
||||
{ code: 'TM', name: 'Turkmenistan' },
|
||||
{ code: 'TC', name: 'Turks and Caicos Islands' },
|
||||
{ code: 'TV', name: 'Tuvalu' },
|
||||
{ code: 'UM', name: 'U.S. Minor Outlying Islands' },
|
||||
{ code: 'PU', name: 'U.S. Miscellaneous Pacific Islands' },
|
||||
{ code: 'VI', name: 'U.S. Virgin Islands' },
|
||||
{ code: 'UG', name: 'Uganda' },
|
||||
{ code: 'UA', name: 'Ukraine' },
|
||||
{ code: 'AE', name: 'United Arab Emirates' },
|
||||
{ code: 'GB', name: 'United Kingdom' },
|
||||
{ code: 'US', name: 'United States' },
|
||||
{ code: 'UY', name: 'Uruguay' },
|
||||
{ code: 'UZ', name: 'Uzbekistan' },
|
||||
{ code: 'VU', name: 'Vanuatu' },
|
||||
{ code: 'VA', name: 'Vatican City' },
|
||||
// { code: 'VE', name: 'Venezuela' }, // blocked
|
||||
{ code: 'VN', name: 'Vietnam' },
|
||||
{ code: 'WK', name: 'Wake Island' },
|
||||
{ code: 'WF', name: 'Wallis and Futuna' },
|
||||
{ code: 'EH', name: 'Western Sahara' },
|
||||
{ code: 'YE', name: 'Yemen' },
|
||||
{ code: 'ZM', name: 'Zambia' },
|
||||
{ code: 'ZW', name: 'Zimbabwe' },
|
||||
]
|
||||
|
||||
export default countries
|
||||
@@ -0,0 +1,7 @@
|
||||
export const subscriptionUpdateUrl = '/user/subscription/update'
|
||||
export const cancelPendingSubscriptionChangeUrl =
|
||||
'/user/subscription/cancel-pending'
|
||||
export const cancelSubscriptionUrl = '/user/subscription/cancel'
|
||||
export const redirectAfterCancelSubscriptionUrl = '/user/subscription/canceled'
|
||||
export const extendTrialUrl = '/user/subscription/extend'
|
||||
export const reactivateSubscriptionUrl = '/user/subscription/reactivate'
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
type Target = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
||||
|
||||
function useValidateField<T extends { target: Target }>() {
|
||||
const [isValid, setIsValid] = useState(true)
|
||||
|
||||
const validate = (e: T) => {
|
||||
let isValid = e.target.checkValidity()
|
||||
|
||||
if (e.target.required) {
|
||||
isValid = isValid && Boolean(e.target.value.trim().length)
|
||||
}
|
||||
|
||||
setIsValid(isValid)
|
||||
}
|
||||
|
||||
return { validate, isValid }
|
||||
}
|
||||
|
||||
export default useValidateField
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_967_5711" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="17" height="17">
|
||||
<rect x="0.396484" y="0.64917" width="16" height="16" fill="#D9D9D9"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_967_5711)">
|
||||
<path d="M8.39636 11.1826L5.9297 13.0659C5.80747 13.1659 5.67414 13.213 5.5297 13.2072C5.38525 13.2019 5.25747 13.1603 5.14636 13.0826C5.03525 13.0048 4.94925 12.8992 4.88836 12.7659C4.82703 12.6326 4.82414 12.4881 4.8797 12.3326L5.8297 9.24923L3.41303 7.53257C3.2797 7.44368 3.19636 7.32701 3.16303 7.18257C3.1297 7.03812 3.13525 6.90479 3.1797 6.78257C3.22414 6.66035 3.30192 6.5519 3.41303 6.45724C3.52414 6.36301 3.65747 6.3159 3.81303 6.3159H6.79636L7.76303 3.1159C7.81859 2.96035 7.90481 2.84079 8.0217 2.75724C8.13814 2.67412 8.26303 2.63257 8.39636 2.63257C8.5297 2.63257 8.65459 2.67412 8.77103 2.75724C8.88792 2.84079 8.97414 2.96035 9.0297 3.1159L9.99636 6.3159H12.9797C13.1353 6.3159 13.2686 6.36301 13.3797 6.45724C13.4908 6.5519 13.5686 6.66035 13.613 6.78257C13.6575 6.90479 13.663 7.03812 13.6297 7.18257C13.5964 7.32701 13.513 7.44368 13.3797 7.53257L10.963 9.24923L11.913 12.3326C11.9686 12.4881 11.9659 12.6326 11.905 12.7659C11.8437 12.8992 11.7575 13.0048 11.6464 13.0826C11.5353 13.1603 11.4075 13.2019 11.263 13.2072C11.1186 13.213 10.9853 13.1659 10.863 13.0659L8.39636 11.1826Z" fill="url(#paint0_linear_967_5711)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_967_5711" x1="13.6511" y1="2.63257" x2="1.37486" y2="8.15906" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#214475"/>
|
||||
<stop offset="0.295154" stop-color="#254C84"/>
|
||||
<stop offset="1" stop-color="#6597E0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,9 @@
|
||||
export default function isInFreeTrial(trialEndsAt?: string | null) {
|
||||
if (!trialEndsAt) return false
|
||||
|
||||
const endDate = new Date(trialEndsAt)
|
||||
|
||||
if (endDate.getTime() < Date.now()) return false
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export default function isMonthlyCollaboratorPlan(
|
||||
planCode: string,
|
||||
isGroupPlan?: boolean
|
||||
) {
|
||||
return (
|
||||
planCode.indexOf('collaborator') !== -1 &&
|
||||
planCode.indexOf('ann') === -1 &&
|
||||
!isGroupPlan
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isRecurlyLoaded() {
|
||||
return typeof recurly !== 'undefined'
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function getRecurlyGroupPlanCode(
|
||||
planCode: string,
|
||||
size: string,
|
||||
usage: string
|
||||
) {
|
||||
return `group_${planCode}_${size}_${usage}`
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { SubscriptionPricingState } from '@recurly/recurly-js'
|
||||
import { PriceForDisplayData } from '../../../../../types/subscription/plan'
|
||||
import { CurrencyCode } from '../../../../../types/subscription/currency'
|
||||
import { getRecurlyGroupPlanCode } from './recurly-group-plan-code'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { formatCurrency } from '@/shared/utils/currency'
|
||||
|
||||
function queryRecurlyPlanPrice(planCode: string, currency: CurrencyCode) {
|
||||
return new Promise(resolve => {
|
||||
recurly.Pricing.Subscription()
|
||||
.plan(planCode, { quantity: 1 })
|
||||
.currency(currency)
|
||||
.catch(debugConsole.error)
|
||||
.done(response => {
|
||||
if (response) {
|
||||
resolve(response)
|
||||
} else {
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function formatPriceForDisplayData(
|
||||
price: string,
|
||||
taxRate: number,
|
||||
currencyCode: CurrencyCode,
|
||||
locale: string
|
||||
): PriceForDisplayData {
|
||||
const totalPriceExTax = parseFloat(price)
|
||||
let taxAmount = totalPriceExTax * taxRate
|
||||
if (isNaN(taxAmount)) {
|
||||
taxAmount = 0
|
||||
}
|
||||
const totalWithTax = totalPriceExTax + taxAmount
|
||||
|
||||
return {
|
||||
totalForDisplay: formatCurrency(totalWithTax, currencyCode, locale, true),
|
||||
totalAsNumber: totalWithTax,
|
||||
subtotal: formatCurrency(totalPriceExTax, currencyCode, locale),
|
||||
tax: formatCurrency(taxAmount, currencyCode, locale),
|
||||
includesTax: taxAmount !== 0,
|
||||
}
|
||||
}
|
||||
|
||||
function getPerUserDisplayPrice(
|
||||
totalPrice: number,
|
||||
currency: CurrencyCode,
|
||||
size: string,
|
||||
locale: string
|
||||
): string {
|
||||
return formatCurrency(totalPrice / parseInt(size), currency, locale, true)
|
||||
}
|
||||
|
||||
export async function loadDisplayPriceWithTaxPromise(
|
||||
planCode: string,
|
||||
currencyCode: CurrencyCode,
|
||||
taxRate: number,
|
||||
locale: string
|
||||
) {
|
||||
if (!recurly) return
|
||||
|
||||
const price = (await queryRecurlyPlanPrice(
|
||||
planCode,
|
||||
currencyCode
|
||||
)) as SubscriptionPricingState['price']
|
||||
if (price)
|
||||
return formatPriceForDisplayData(
|
||||
price.next.total,
|
||||
taxRate,
|
||||
currencyCode,
|
||||
locale
|
||||
)
|
||||
}
|
||||
|
||||
export async function loadGroupDisplayPriceWithTaxPromise(
|
||||
groupPlanCode: string,
|
||||
currencyCode: CurrencyCode,
|
||||
taxRate: number,
|
||||
size: string,
|
||||
usage: string,
|
||||
locale: string
|
||||
) {
|
||||
if (!recurly) return
|
||||
|
||||
const planCode = getRecurlyGroupPlanCode(groupPlanCode, size, usage)
|
||||
const price = await loadDisplayPriceWithTaxPromise(
|
||||
planCode,
|
||||
currencyCode,
|
||||
taxRate,
|
||||
locale
|
||||
)
|
||||
|
||||
if (price) {
|
||||
price.perUserDisplayPrice = getPerUserDisplayPrice(
|
||||
price.totalAsNumber,
|
||||
currencyCode,
|
||||
size,
|
||||
locale
|
||||
)
|
||||
}
|
||||
|
||||
return price
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Nullable } from '../../../../../types/utils'
|
||||
import isInFreeTrial from './is-in-free-trial'
|
||||
import isMonthlyCollaboratorPlan from './is-monthly-collaborator-plan'
|
||||
|
||||
export default function showDowngradeOption(
|
||||
planCode: string,
|
||||
isGroupPlan?: boolean,
|
||||
trialEndsAt?: string | null,
|
||||
pausedAt?: Nullable<string>,
|
||||
remainingPauseCycles?: Nullable<number>
|
||||
) {
|
||||
return (
|
||||
!pausedAt &&
|
||||
!remainingPauseCycles &&
|
||||
isMonthlyCollaboratorPlan(planCode, isGroupPlan) &&
|
||||
!isInFreeTrial(trialEndsAt)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user