first commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
import MaterialIcon from '../../../../shared/components/material-icon'
type RowLinkProps = {
href: string
heading: string
subtext: string
icon: string
}
export function RowLink({ href, heading, subtext, icon }: RowLinkProps) {
return (
<li className="list-group-item row-link">
<a href={href} className="row-link-inner">
<MaterialIcon type={icon} className="p-2 p-md-3" />
<div className="flex-grow-1">
<strong>{heading}</strong>
<div>{subtext}</div>
</div>
<MaterialIcon type="keyboard_arrow_right" className="p-2 p-md-3" />
</a>
</li>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
import { useTranslation } from 'react-i18next'
import { putJSON } from '../../../../../../../infrastructure/fetch-json'
import { extendTrialUrl } from '../../../../../data/subscription-url'
import { useLocation } from '../../../../../../../shared/hooks/use-location'
import { debugConsole } from '@/utils/debugging'
import OLButton from '@/features/ui/components/ol/ol-button'
export default function ExtendTrialButton({
isButtonDisabled,
isLoading,
runAsyncSecondaryAction,
}: {
isButtonDisabled: boolean
isLoading: boolean
runAsyncSecondaryAction: (promise: Promise<unknown>) => Promise<unknown>
}) {
const { t } = useTranslation()
const buttonText = t('ill_take_it')
const location = useLocation()
async function handleExtendTrial() {
try {
await runAsyncSecondaryAction(putJSON(extendTrialUrl))
location.reload()
} catch (e) {
debugConsole.error(e)
}
}
return (
<OLButton
variant="primary"
onClick={handleExtendTrial}
disabled={isButtonDisabled}
isLoading={isLoading}
loadingLabel={t('processing_uppercase') + '…'}
>
{buttonText}
</OLButton>
)
}

View File

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

View File

@@ -0,0 +1,51 @@
import { useTranslation } from 'react-i18next'
import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import isInFreeTrial from '../../../../../util/is-in-free-trial'
import { PaidSubscription } from '../../../../../../../../../types/subscription/dashboard/subscription'
export function ChangeToGroupPlan() {
const { t } = useTranslation()
const { handleOpenModal, personalSubscription } =
useSubscriptionDashboardContext()
// TODO: Better way to get PaidSubscription/trialEndsAt
const subscription =
personalSubscription && 'payment' in personalSubscription
? (personalSubscription as PaidSubscription)
: null
const handleClick = () => {
handleOpenModal('change-to-group')
}
return (
<div className="card-gray text-center mt-3 p-3">
<h2 style={{ marginTop: 0 }}>{t('looking_multiple_licenses')}</h2>
<p style={{ margin: 0 }}>{t('reduce_costs_group_licenses')}</p>
<br />
{isInFreeTrial(subscription?.payment?.trialEndsAt) ? (
<>
<OLTooltip
id="disabled-change-to-group-plan"
description={t(
'sorry_you_can_only_change_to_group_from_trial_via_support'
)}
overlayProps={{ placement: 'top' }}
>
<div>
<OLButton variant="primary" disabled>
{t('change_to_group_plan')}
</OLButton>
</div>
</OLTooltip>
</>
) : (
<OLButton variant="primary" onClick={handleClick}>
{t('change_to_group_plan')}
</OLButton>
)}
</div>
)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,348 @@
import { useEffect, useState } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import { PaidSubscription } from '../../../../../../../../../../types/subscription/dashboard/subscription'
import { PriceForDisplayData } from '../../../../../../../../../../types/subscription/plan'
import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
import getMeta from '../../../../../../../../utils/meta'
import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
import GenericErrorAlert from '../../../../generic-error-alert'
import { subscriptionUpdateUrl } from '../../../../../../data/subscription-url'
import { getRecurlyGroupPlanCode } from '../../../../../../util/recurly-group-plan-code'
import { useLocation } from '../../../../../../../../shared/hooks/use-location'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
import { useContactUsModal } from '@/shared/hooks/use-contact-us-modal'
import { UserProvider } from '@/shared/context/user-context'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLNotification from '@/features/ui/components/ol/ol-notification'
const educationalPercentDiscount = 40
function GroupPlanCollaboratorCount({ planCode }: { planCode: string }) {
const { t } = useTranslation()
if (planCode === 'collaborator') {
return (
<>
{t('collabs_per_proj', {
collabcount: 10,
})}
</>
)
} else if (planCode === 'professional') {
return <>{t('unlimited_collabs')}</>
}
return null
}
function GroupPrice({
groupPlanToChangeToPrice,
queryingGroupPlanToChangeToPrice,
}: {
groupPlanToChangeToPrice?: PriceForDisplayData
queryingGroupPlanToChangeToPrice: boolean
}) {
const { t } = useTranslation()
const totalPrice =
!queryingGroupPlanToChangeToPrice &&
groupPlanToChangeToPrice?.totalForDisplay
? groupPlanToChangeToPrice.totalForDisplay
: '…'
const perUserPrice =
!queryingGroupPlanToChangeToPrice &&
groupPlanToChangeToPrice?.perUserDisplayPrice
? groupPlanToChangeToPrice.perUserDisplayPrice
: '…'
return (
<>
<span aria-hidden>
{totalPrice} <span className="small">/ {t('year')}</span>
</span>
<span className="sr-only">
{queryingGroupPlanToChangeToPrice
? t('loading_prices')
: t('x_price_per_year', {
price: groupPlanToChangeToPrice?.totalForDisplay,
})}
</span>
<span className="circle-subtext">
<span aria-hidden>
{t('x_price_per_user', {
price: perUserPrice,
})}
</span>
<span className="sr-only">
{queryingGroupPlanToChangeToPrice
? t('loading_prices')
: t('x_price_per_user', {
price: perUserPrice,
})}
</span>
</span>
</>
)
}
export function ChangeToGroupModal() {
const modalId = 'change-to-group'
const { t } = useTranslation()
const {
groupPlanToChangeToCode,
groupPlanToChangeToPrice,
groupPlanToChangeToPriceError,
groupPlanToChangeToSize,
groupPlanToChangeToUsage,
handleCloseModal,
modalIdShown,
queryingGroupPlanToChangeToPrice,
setGroupPlanToChangeToCode,
setGroupPlanToChangeToSize,
setGroupPlanToChangeToUsage,
} = useSubscriptionDashboardContext()
const { modal: contactModal, showModal: showContactModal } =
useContactUsModal({ autofillProjectUrl: false })
const groupPlans = getMeta('ol-groupPlans')
const showGroupDiscount = getMeta('ol-showGroupDiscount')
const personalSubscription = getMeta('ol-subscription') as PaidSubscription
const [error, setError] = useState(false)
const [inflight, setInflight] = useState(false)
const location = useLocation()
async function upgrade() {
setError(false)
setInflight(true)
try {
await postJSON(subscriptionUpdateUrl, {
body: {
plan_code: getRecurlyGroupPlanCode(
groupPlanToChangeToCode,
groupPlanToChangeToSize,
groupPlanToChangeToUsage
),
},
})
location.reload()
} catch (e) {
setError(true)
setInflight(false)
}
}
useEffect(() => {
if (personalSubscription.plan.planCode.includes('professional')) {
setGroupPlanToChangeToCode('professional')
}
}, [personalSubscription, setGroupPlanToChangeToCode])
if (
modalIdShown !== modalId ||
!groupPlans ||
!groupPlans.plans ||
!groupPlans.sizes ||
!groupPlanToChangeToCode
)
return null
return (
<>
<UserProvider>{contactModal}</UserProvider>
<OLModal
id={modalId}
show
animation
onHide={handleCloseModal}
backdrop="static"
>
<OLModalHeader closeButton>
<OLModalTitle className="lh-sm">
{t('customize_your_group_subscription')}
{showGroupDiscount && (
<p className="group-subscription-modal-title-discount">
{t('save_x_or_more', { percentage: '10%' })}
</p>
)}
</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<div className="container-fluid plans group-subscription-modal">
{groupPlanToChangeToPriceError && <GenericErrorAlert />}
<div className="row">
<div className="col-md-6 text-center">
<div className="circle circle-lg mb-4 mx-auto">
<GroupPrice
groupPlanToChangeToPrice={groupPlanToChangeToPrice}
queryingGroupPlanToChangeToPrice={
queryingGroupPlanToChangeToPrice
}
/>
</div>
<p>{t('each_user_will_have_access_to')}:</p>
<ul className="list-unstyled">
<li className="mb-3">
<strong>
<GroupPlanCollaboratorCount
planCode={groupPlanToChangeToCode}
/>
</strong>
</li>
<li>
<strong>{t('all_premium_features')}</strong>
</li>
<li>{t('sync_dropbox_github')}</li>
<li>{t('full_doc_history')}</li>
<li>{t('track_changes')}</li>
<li>
<span aria-hidden>+ {t('more').toLowerCase()}</span>
<span className="sr-only">{t('plus_more')}</span>
</li>
</ul>
</div>
<div className="col-md-6">
<form className="form">
<fieldset className="form-group">
<legend className="legend-as-label">{t('plan')}</legend>
{groupPlans.plans.map(option => (
<div key={option.code}>
<OLFormCheckbox
type="radio"
name="plan-code"
value={option.code}
id={`plan-option-${option.code}`}
onChange={() =>
setGroupPlanToChangeToCode(option.code)
}
checked={option.code === groupPlanToChangeToCode}
label={option.display}
/>
</div>
))}
</fieldset>
<OLFormGroup controlId="size">
<OLFormLabel>{t('number_of_users')}</OLFormLabel>
<OLFormSelect
name="size"
value={groupPlanToChangeToSize}
onChange={e => setGroupPlanToChangeToSize(e.target.value)}
>
{groupPlans.sizes.map(size => (
<option key={`size-option-${size}`}>{size}</option>
))}
</OLFormSelect>
</OLFormGroup>
<OLFormCheckbox
id="usage"
type="checkbox"
autoComplete="off"
checked={groupPlanToChangeToUsage === 'educational'}
onChange={e => {
if (e.target.checked) {
setGroupPlanToChangeToUsage('educational')
} else {
setGroupPlanToChangeToUsage('enterprise')
}
}}
label={t(
'apply_educational_discount_description_with_group_discount'
)}
/>
</form>
</div>
</div>
<div className="educational-discount-badge pt-4 text-center">
{groupPlanToChangeToUsage === 'educational' && (
<p className="applied">
{t('educational_percent_discount_applied', {
percent: educationalPercentDiscount,
})}
</p>
)}
</div>
</div>
</OLModalBody>
<OLModalFooter>
<div className="text-center">
{groupPlanToChangeToPrice?.includesTax && (
<p>
<Trans
i18nKey="total_with_subtotal_and_tax"
values={{
total: groupPlanToChangeToPrice.totalForDisplay,
subtotal: groupPlanToChangeToPrice.subtotal,
tax: groupPlanToChangeToPrice.tax,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[
/* eslint-disable-next-line react/jsx-key */
<strong />,
]}
/>
</p>
)}
<p>
<strong>
{t('new_subscription_will_be_billed_immediately')}
</strong>
</p>
<hr className="thin my-3" />
{error && (
<OLNotification
type="error"
aria-live="polite"
content={
<>
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
{t('generic_if_problem_continues_contact_us')}.
</>
}
/>
)}
<OLButton
variant="primary"
size="lg"
disabled={
queryingGroupPlanToChangeToPrice ||
!groupPlanToChangeToPrice ||
inflight
}
onClick={upgrade}
isLoading={inflight}
loadingLabel={t('processing_uppercase') + '…'}
>
{t('upgrade_now')}
</OLButton>
<hr className="thin my-3" />
<OLButton
variant="link"
className="btn-inline-link"
onClick={showContactModal}
>
{t('need_more_than_x_licenses', {
x: 20,
})}{' '}
{t('please_get_in_touch')}
</OLButton>
</div>
</OLModalFooter>
</OLModal>
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
}

View File

@@ -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
}

View 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 dIvoire' },
{ 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

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -0,0 +1,10 @@
export default function isMonthlyCollaboratorPlan(
planCode: string,
isGroupPlan?: boolean
) {
return (
planCode.indexOf('collaborator') !== -1 &&
planCode.indexOf('ann') === -1 &&
!isGroupPlan
)
}

View File

@@ -0,0 +1,3 @@
export function isRecurlyLoaded() {
return typeof recurly !== 'undefined'
}

View File

@@ -0,0 +1,7 @@
export function getRecurlyGroupPlanCode(
planCode: string,
size: string,
usage: string
) {
return `group_${planCode}_${size}_${usage}`
}

View File

@@ -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
}

View File

@@ -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)
)
}