first commit
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import { PriceExceptions } from '../../../shared/price-exceptions'
|
||||
import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
|
||||
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
|
||||
import { CancelSubscriptionButton } from './cancel-subscription-button'
|
||||
import { CancelSubscription } from './cancel-plan/cancel-subscription'
|
||||
import { TrialEnding } from './trial-ending'
|
||||
import { ChangePlanModal } from './change-plan/modals/change-plan-modal'
|
||||
import { ConfirmChangePlanModal } from './change-plan/modals/confirm-change-plan-modal'
|
||||
import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
|
||||
import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
|
||||
import { CancelAiAddOnModal } from '@/features/subscription/components/dashboard/states/active/change-plan/modals/cancel-ai-add-on-modal'
|
||||
import { WritefullBundleManagementModal } from '@/features/subscription/components/dashboard/states/active/change-plan/modals/writefull-bundle-management-modal'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import isInFreeTrial from '../../../../util/is-in-free-trial'
|
||||
import AddOns from '@/features/subscription/components/dashboard/states/active/add-ons'
|
||||
import {
|
||||
AI_ADD_ON_CODE,
|
||||
AI_STANDALONE_PLAN_CODE,
|
||||
isStandaloneAiPlanCode,
|
||||
} from '@/features/subscription/data/add-on-codes'
|
||||
import getMeta from '@/utils/meta'
|
||||
import SubscriptionRemainder from '@/features/subscription/components/dashboard/states/active/subscription-remainder'
|
||||
import { sendMB } from '../../../../../../infrastructure/event-tracking'
|
||||
import PauseSubscriptionModal from '@/features/subscription/components/dashboard/pause-modal'
|
||||
import LoadingSpinner from '@/shared/components/loading-spinner'
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import { useLocation } from '@/shared/hooks/use-location'
|
||||
import { FlashMessage } from '@/features/subscription/components/dashboard/states/active/flash-message'
|
||||
import Notification from '@/shared/components/notification'
|
||||
|
||||
export function ActiveSubscriptionNew({
|
||||
subscription,
|
||||
}: {
|
||||
subscription: PaidSubscription
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
recurlyLoadError,
|
||||
setModalIdShown,
|
||||
showCancellation,
|
||||
institutionMemberships,
|
||||
memberGroupSubscriptions,
|
||||
getFormattedRenewalDate,
|
||||
} = useSubscriptionDashboardContext()
|
||||
const cancelPauseReq = useAsync()
|
||||
const { isError: isErrorPause } = cancelPauseReq
|
||||
|
||||
if (showCancellation) return <CancelSubscription />
|
||||
|
||||
const onStandalonePlan = isStandaloneAiPlanCode(subscription.planCode)
|
||||
|
||||
let planName
|
||||
if (onStandalonePlan) {
|
||||
planName = 'Overleaf Free'
|
||||
if (institutionMemberships && institutionMemberships.length > 0) {
|
||||
planName = 'Overleaf Professional'
|
||||
}
|
||||
if (memberGroupSubscriptions.length > 0) {
|
||||
if (
|
||||
memberGroupSubscriptions.some(s => s.planLevelName === 'Professional')
|
||||
) {
|
||||
planName = 'Overleaf Professional'
|
||||
} else {
|
||||
planName = 'Overleaf Standard'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
planName = subscription.plan.name
|
||||
}
|
||||
|
||||
const handlePlanChange = () => setModalIdShown('change-plan')
|
||||
const handleManageOnWritefull = () => setModalIdShown('manage-on-writefull')
|
||||
const handleCancelClick = (addOnCode: string) => {
|
||||
if ([AI_STANDALONE_PLAN_CODE, AI_ADD_ON_CODE].includes(addOnCode)) {
|
||||
setModalIdShown('cancel-ai-add-on')
|
||||
}
|
||||
}
|
||||
const hasPendingPause = Boolean(
|
||||
subscription.payment.state === 'active' &&
|
||||
subscription.payment.remainingPauseCycles &&
|
||||
subscription.payment.remainingPauseCycles > 0
|
||||
)
|
||||
|
||||
const isLegacyPlan =
|
||||
subscription.payment.totalLicenses !==
|
||||
subscription.payment.additionalLicenses
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="notification-list">
|
||||
<FlashMessage />
|
||||
|
||||
{isErrorPause && (
|
||||
<Notification
|
||||
type="error"
|
||||
content={t('generic_something_went_wrong')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="h3 fw-bold">{t('billing')}</h2>
|
||||
<p className="mb-1">
|
||||
{subscription.plan.annual ? (
|
||||
<Trans
|
||||
i18nKey="billed_annually_at"
|
||||
values={{ price: subscription.payment.displayPrice }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<i />,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="billed_monthly_at"
|
||||
values={{ price: subscription.payment.displayPrice }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<i />,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
<p className="mb-1">
|
||||
<Trans
|
||||
i18nKey="renews_on"
|
||||
values={{ date: subscription.payment.nextPaymentDueDate }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[<strong />]} // eslint-disable-line react/jsx-key
|
||||
/>
|
||||
</p>
|
||||
<div>
|
||||
<a
|
||||
href={subscription.payment.accountManagementLink}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="me-2"
|
||||
>
|
||||
{t('view_invoices')}
|
||||
</a>
|
||||
<a
|
||||
href={subscription.payment.billingDetailsLink}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{t('view_billing_details')}
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<PriceExceptions subscription={subscription} />
|
||||
{!recurlyLoadError && (
|
||||
<p>
|
||||
<i>
|
||||
<SubscriptionRemainder subscription={subscription} hideTime />
|
||||
</i>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<hr />
|
||||
<h2 className="h3 fw-bold">{t('plan')}</h2>
|
||||
<h3 className="h5 mt-0 mb-1 fw-bold">{planName}</h3>
|
||||
{subscription.pendingPlan &&
|
||||
subscription.pendingPlan.name !== subscription.plan.name && (
|
||||
<p className="mb-1">{t('want_change_to_apply_before_plan_end')}</p>
|
||||
)}
|
||||
{isInFreeTrial(subscription.payment.trialEndsAt) &&
|
||||
subscription.payment.trialEndsAtFormatted && (
|
||||
<TrialEnding
|
||||
trialEndsAtFormatted={subscription.payment.trialEndsAtFormatted}
|
||||
className="mb-1"
|
||||
/>
|
||||
)}
|
||||
{subscription.payment.totalLicenses > 0 && (
|
||||
<p className="mb-1">
|
||||
{isLegacyPlan && subscription.payment.additionalLicenses > 0 ? (
|
||||
<Trans
|
||||
i18nKey="plus_x_additional_licenses_for_a_total_of_y_licenses"
|
||||
values={{
|
||||
count: subscription.payment.totalLicenses,
|
||||
additionalLicenses: subscription.payment.additionalLicenses,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[<strong />, <strong />]} // eslint-disable-line react/jsx-key
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="supports_up_to_x_licenses"
|
||||
values={{ count: subscription.payment.totalLicenses }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[<strong />]} // eslint-disable-line react/jsx-key
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{hasPendingPause && (
|
||||
<>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="your_subscription_will_pause_on"
|
||||
values={{
|
||||
planName: subscription.plan.name,
|
||||
pauseDate: subscription.payment.nextPaymentDueAt,
|
||||
reactivationDate: getFormattedRenewalDate(),
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
<p>{t('you_can_still_use_your_premium_features')}</p>
|
||||
</>
|
||||
)}
|
||||
{!onStandalonePlan && (
|
||||
<p className="mb-1">
|
||||
{subscription.plan.annual
|
||||
? t('x_price_per_year', {
|
||||
price: subscription.payment.planOnlyDisplayPrice,
|
||||
})
|
||||
: t('x_price_per_month', {
|
||||
price: subscription.payment.planOnlyDisplayPrice,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{!recurlyLoadError && (
|
||||
<PlanActions
|
||||
subscription={subscription}
|
||||
onStandalonePlan={onStandalonePlan}
|
||||
handlePlanChange={handlePlanChange}
|
||||
hasPendingPause={hasPendingPause}
|
||||
cancelPauseReq={cancelPauseReq}
|
||||
/>
|
||||
)}
|
||||
<hr />
|
||||
<AddOns
|
||||
subscription={subscription}
|
||||
onStandalonePlan={onStandalonePlan}
|
||||
handleCancelClick={handleCancelClick}
|
||||
handleManageOnWritefull={handleManageOnWritefull}
|
||||
/>
|
||||
|
||||
<ChangePlanModal />
|
||||
<ConfirmChangePlanModal />
|
||||
<KeepCurrentPlanModal />
|
||||
<ChangeToGroupModal />
|
||||
<CancelAiAddOnModal />
|
||||
<WritefullBundleManagementModal />
|
||||
<PauseSubscriptionModal />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type PlanActionsProps = {
|
||||
subscription: PaidSubscription
|
||||
onStandalonePlan: boolean
|
||||
handlePlanChange: () => void
|
||||
hasPendingPause: boolean
|
||||
cancelPauseReq: ReturnType<typeof useAsync>
|
||||
}
|
||||
|
||||
function PlanActions({
|
||||
subscription,
|
||||
onStandalonePlan,
|
||||
handlePlanChange,
|
||||
hasPendingPause,
|
||||
cancelPauseReq,
|
||||
}: PlanActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const isSubscriptionEligibleForFlexibleGroupLicensing = getMeta(
|
||||
'ol-canUseFlexibleLicensing'
|
||||
)
|
||||
const location = useLocation()
|
||||
const { runAsync: runAsyncCancelPause, isLoading: isLoadingCancelPause } =
|
||||
cancelPauseReq
|
||||
|
||||
const handleCancelPendingPauseClick = async () => {
|
||||
try {
|
||||
await runAsyncCancelPause(postJSON('/user/subscription/pause/0'))
|
||||
const newUrl = new URL(location.toString())
|
||||
newUrl.searchParams.set('flash', 'unpaused')
|
||||
window.history.replaceState(null, '', newUrl)
|
||||
location.reload()
|
||||
} catch (e) {
|
||||
debugConsole.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
{isSubscriptionEligibleForFlexibleGroupLicensing ? (
|
||||
<FlexibleGroupLicensingActions subscription={subscription} />
|
||||
) : (
|
||||
<>
|
||||
{!hasPendingPause && !subscription.payment.hasPastDueInvoice && (
|
||||
<OLButton variant="secondary" onClick={handlePlanChange}>
|
||||
{t('change_plan')}
|
||||
</OLButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{hasPendingPause && (
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={handleCancelPendingPauseClick}
|
||||
disabled={isLoadingCancelPause}
|
||||
>
|
||||
{isLoadingCancelPause ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
t('unpause_subscription')
|
||||
)}
|
||||
</OLButton>
|
||||
)}
|
||||
{!onStandalonePlan && (
|
||||
<>
|
||||
{' '}
|
||||
<CancelSubscriptionButton />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FlexibleGroupLicensingActions({
|
||||
subscription,
|
||||
}: {
|
||||
subscription: PaidSubscription
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (subscription.pendingPlan) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isProfessionalPlan = subscription.planCode
|
||||
.toLowerCase()
|
||||
.includes('professional')
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isProfessionalPlan && (
|
||||
<>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
href="/user/subscription/group/upgrade-subscription"
|
||||
onClick={() =>
|
||||
sendMB('flex-upgrade', { location: 'upgrade-plan-button' })
|
||||
}
|
||||
>
|
||||
{t('upgrade_plan')}
|
||||
</OLButton>{' '}
|
||||
</>
|
||||
)}
|
||||
{subscription.plan.membersLimitAddOn === 'additional-license' && (
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
href="/user/subscription/group/add-users"
|
||||
onClick={() => sendMB('flex-add-users')}
|
||||
>
|
||||
{t('buy_more_licenses')}
|
||||
</OLButton>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import { PriceExceptions } from '../../../shared/price-exceptions'
|
||||
import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
|
||||
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
|
||||
import { CancelSubscriptionButton } from './cancel-subscription-button'
|
||||
import { CancelSubscription } from './cancel-plan/cancel-subscription'
|
||||
import { PendingPlanChange } from './pending-plan-change'
|
||||
import { TrialEnding } from './trial-ending'
|
||||
import { PendingAdditionalLicenses } from './pending-additional-licenses'
|
||||
import { ContactSupportToChangeGroupPlan } from './contact-support-to-change-group-plan'
|
||||
import SubscriptionRemainder from './subscription-remainder'
|
||||
import isInFreeTrial from '../../../../util/is-in-free-trial'
|
||||
import { ChangePlanModal } from './change-plan/modals/change-plan-modal'
|
||||
import { ConfirmChangePlanModal } from './change-plan/modals/confirm-change-plan-modal'
|
||||
import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
|
||||
import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import PauseSubscriptionModal from '../../pause-modal'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { FlashMessage } from './flash-message'
|
||||
import { useLocation } from '@/shared/hooks/use-location'
|
||||
import LoadingSpinner from '@/shared/components/loading-spinner'
|
||||
|
||||
export function ActiveSubscription({
|
||||
subscription,
|
||||
}: {
|
||||
subscription: PaidSubscription
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
recurlyLoadError,
|
||||
setModalIdShown,
|
||||
showCancellation,
|
||||
getFormattedRenewalDate,
|
||||
} = useSubscriptionDashboardContext()
|
||||
const {
|
||||
isError: isErrorPause,
|
||||
runAsync: runAsyncCancelPause,
|
||||
isLoading: isLoadingCancelPause,
|
||||
} = useAsync()
|
||||
const location = useLocation()
|
||||
|
||||
if (showCancellation) return <CancelSubscription />
|
||||
|
||||
const hasPendingPause =
|
||||
subscription.payment.state === 'active' &&
|
||||
subscription.payment.remainingPauseCycles &&
|
||||
subscription.payment.remainingPauseCycles > 0
|
||||
|
||||
const handleCancelPendingPauseClick = async () => {
|
||||
try {
|
||||
await runAsyncCancelPause(postJSON('/user/subscription/pause/0'))
|
||||
const newUrl = new URL(location.toString())
|
||||
newUrl.searchParams.set('flash', 'unpaused')
|
||||
window.history.replaceState(null, '', newUrl)
|
||||
location.reload()
|
||||
} catch (e) {
|
||||
debugConsole.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="notification-list">
|
||||
<FlashMessage />
|
||||
|
||||
{isErrorPause && (
|
||||
<Notification
|
||||
type="error"
|
||||
content={t('generic_something_went_wrong')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p>
|
||||
{!hasPendingPause && (
|
||||
<Trans
|
||||
i18nKey="currently_subscribed_to_plan"
|
||||
values={{
|
||||
planName: subscription.plan.name,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{subscription.pendingPlan && (
|
||||
<>
|
||||
{' '}
|
||||
<PendingPlanChange subscription={subscription} />
|
||||
</>
|
||||
)}
|
||||
{!subscription.pendingPlan &&
|
||||
subscription.payment.additionalLicenses > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
<PendingAdditionalLicenses
|
||||
additionalLicenses={subscription.payment.additionalLicenses}
|
||||
totalLicenses={subscription.payment.totalLicenses}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!recurlyLoadError &&
|
||||
!subscription.groupPlan &&
|
||||
!hasPendingPause &&
|
||||
!subscription.payment.hasPastDueInvoice && (
|
||||
<>
|
||||
{' '}
|
||||
<OLButton
|
||||
variant="link"
|
||||
className="btn-inline-link"
|
||||
onClick={() => setModalIdShown('change-plan')}
|
||||
>
|
||||
{t('change_plan')}
|
||||
</OLButton>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{subscription.pendingPlan &&
|
||||
subscription.pendingPlan.name !== subscription.plan.name && (
|
||||
<p>{t('want_change_to_apply_before_plan_end')}</p>
|
||||
)}
|
||||
{(!subscription.pendingPlan ||
|
||||
subscription.pendingPlan.name === subscription.plan.name) &&
|
||||
subscription.plan.groupPlan && <ContactSupportToChangeGroupPlan />}
|
||||
{isInFreeTrial(subscription.payment.trialEndsAt) &&
|
||||
subscription.payment.trialEndsAtFormatted && (
|
||||
<TrialEnding
|
||||
trialEndsAtFormatted={subscription.payment.trialEndsAtFormatted}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasPendingPause && (
|
||||
<>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="your_subscription_will_pause_on"
|
||||
values={{
|
||||
planName: subscription.plan.name,
|
||||
pauseDate: subscription.payment.nextPaymentDueAt,
|
||||
reactivationDate: getFormattedRenewalDate(),
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
<p>{t('you_can_still_use_your_premium_features')}</p>
|
||||
<p>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={handleCancelPendingPauseClick}
|
||||
disabled={isLoadingCancelPause}
|
||||
>
|
||||
{isLoadingCancelPause ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
t('unpause_subscription')
|
||||
)}
|
||||
</OLButton>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="next_payment_of_x_collectected_on_y"
|
||||
values={{
|
||||
paymentAmmount: subscription.payment.displayPrice,
|
||||
collectionDate: getFormattedRenewalDate(),
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
<PriceExceptions subscription={subscription} />
|
||||
<p className="d-inline-flex flex-wrap gap-1">
|
||||
<a
|
||||
href={subscription.payment.billingDetailsLink}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="btn btn-secondary-info btn-secondary"
|
||||
>
|
||||
{t('update_your_billing_details')}
|
||||
</a>{' '}
|
||||
<a
|
||||
href={subscription.payment.accountManagementLink}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="btn btn-secondary-info btn-secondary"
|
||||
>
|
||||
{t('view_your_invoices')}
|
||||
</a>
|
||||
{!recurlyLoadError && (
|
||||
<>
|
||||
{' '}
|
||||
<CancelSubscriptionButton />
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{!recurlyLoadError && (
|
||||
<>
|
||||
<br />
|
||||
<p>
|
||||
<i>
|
||||
<SubscriptionRemainder subscription={subscription} />
|
||||
</i>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ChangePlanModal />
|
||||
<ConfirmChangePlanModal />
|
||||
<KeepCurrentPlanModal />
|
||||
<ChangeToGroupModal />
|
||||
<PauseSubscriptionModal />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { Dropdown, DropdownMenu, DropdownToggle } from 'react-bootstrap-5'
|
||||
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import {
|
||||
ADD_ON_NAME,
|
||||
AI_ADD_ON_CODE,
|
||||
AI_STANDALONE_ANNUAL_PLAN_CODE,
|
||||
AI_STANDALONE_PLAN_CODE,
|
||||
} from '@/features/subscription/data/add-on-codes'
|
||||
import sparkle from '@/shared/svgs/sparkle.svg'
|
||||
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
|
||||
import { LICENSE_ADD_ON } from '@/features/group-management/components/upgrade-subscription/upgrade-subscription-plan-details'
|
||||
|
||||
type AddOnsProps = {
|
||||
subscription: PaidSubscription
|
||||
onStandalonePlan: boolean
|
||||
handleCancelClick: (code: string) => void
|
||||
handleManageOnWritefull: () => void
|
||||
}
|
||||
|
||||
type AddOnProps = {
|
||||
addOnCode: string
|
||||
displayPrice: string | undefined
|
||||
pendingCancellation: boolean
|
||||
isAnnual: boolean
|
||||
handleCancelClick: (code: string) => void
|
||||
nextBillingDate: string
|
||||
}
|
||||
|
||||
function resolveAddOnName(addOnCode: string) {
|
||||
switch (addOnCode) {
|
||||
case AI_ADD_ON_CODE:
|
||||
case AI_STANDALONE_ANNUAL_PLAN_CODE:
|
||||
case AI_STANDALONE_PLAN_CODE:
|
||||
return ADD_ON_NAME
|
||||
}
|
||||
}
|
||||
|
||||
function AddOn({
|
||||
addOnCode,
|
||||
displayPrice,
|
||||
pendingCancellation,
|
||||
isAnnual,
|
||||
handleCancelClick,
|
||||
nextBillingDate,
|
||||
}: AddOnProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="add-on-card">
|
||||
<div>
|
||||
<img
|
||||
alt="sparkle"
|
||||
className="add-on-card-icon"
|
||||
src={sparkle}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="add-on-card-content">
|
||||
<div className="heading">{resolveAddOnName(addOnCode)}</div>
|
||||
<div className="description small mt-1">
|
||||
{pendingCancellation
|
||||
? t(
|
||||
'your_add_on_has_been_cancelled_and_will_remain_active_until_your_billing_cycle_ends_on',
|
||||
{ nextBillingDate }
|
||||
)
|
||||
: isAnnual
|
||||
? t('x_price_per_year', { price: displayPrice })
|
||||
: t('x_price_per_month', { price: displayPrice })}
|
||||
</div>
|
||||
</div>
|
||||
{!pendingCancellation && (
|
||||
<div className="ms-auto">
|
||||
<Dropdown align="end">
|
||||
<DropdownToggle
|
||||
id="add-on-dropdown-toggle"
|
||||
className="add-on-options-toggle"
|
||||
variant="secondary"
|
||||
>
|
||||
<MaterialIcon
|
||||
type="more_vert"
|
||||
accessibilityLabel={t('more_options')}
|
||||
/>
|
||||
</DropdownToggle>
|
||||
<DropdownMenu flip={false}>
|
||||
<OLDropdownMenuItem
|
||||
onClick={() => handleCancelClick(addOnCode)}
|
||||
as="button"
|
||||
tabIndex={-1}
|
||||
variant="danger"
|
||||
>
|
||||
{t('cancel')}
|
||||
</OLDropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WritefullGrantedAddOn({
|
||||
handleManageOnWritefull,
|
||||
}: {
|
||||
handleManageOnWritefull: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="add-on-card">
|
||||
<div>
|
||||
<img
|
||||
alt="sparkle"
|
||||
className="add-on-card-icon"
|
||||
src={sparkle}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="add-on-card-content">
|
||||
<div className="heading">{ADD_ON_NAME}</div>
|
||||
<div className="description small mt-1">
|
||||
{t('included_as_part_of_your_writefull_subscription')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ms-auto">
|
||||
<Dropdown align="end">
|
||||
<DropdownToggle
|
||||
id="add-on-dropdown-toggle"
|
||||
className="add-on-options-toggle"
|
||||
variant="secondary"
|
||||
>
|
||||
<MaterialIcon
|
||||
type="more_vert"
|
||||
accessibilityLabel={t('more_options')}
|
||||
/>
|
||||
</DropdownToggle>
|
||||
<DropdownMenu flip={false}>
|
||||
<OLDropdownMenuItem tabIndex={-1} onClick={handleManageOnWritefull}>
|
||||
{t('manage_subscription')}
|
||||
</OLDropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddOns({
|
||||
subscription,
|
||||
onStandalonePlan,
|
||||
handleCancelClick,
|
||||
handleManageOnWritefull,
|
||||
}: AddOnsProps) {
|
||||
const { t } = useTranslation()
|
||||
const hasAiAssistViaWritefull = getMeta('ol-hasAiAssistViaWritefull')
|
||||
const addOnsDisplayPrices = onStandalonePlan
|
||||
? {
|
||||
[AI_STANDALONE_PLAN_CODE]: subscription.payment.displayPrice,
|
||||
}
|
||||
: subscription.payment.addOnDisplayPricesWithoutAdditionalLicense
|
||||
const addOnsToDisplay = onStandalonePlan
|
||||
? [{ addOnCode: AI_STANDALONE_PLAN_CODE }]
|
||||
: subscription.addOns?.filter(addOn => addOn.addOnCode !== LICENSE_ADD_ON)
|
||||
|
||||
const hasAddons =
|
||||
(addOnsToDisplay && addOnsToDisplay.length > 0) || hasAiAssistViaWritefull
|
||||
return (
|
||||
<>
|
||||
<h2 className="h3 fw-bold">{t('add_ons')}</h2>
|
||||
{hasAddons ? (
|
||||
<>
|
||||
{addOnsToDisplay?.map(addOn => (
|
||||
<AddOn
|
||||
addOnCode={addOn.addOnCode}
|
||||
key={addOn.addOnCode}
|
||||
isAnnual={Boolean(subscription.plan.annual)}
|
||||
handleCancelClick={handleCancelClick}
|
||||
pendingCancellation={
|
||||
subscription.pendingPlan !== undefined &&
|
||||
(subscription.pendingPlan.addOns ?? []).every(
|
||||
pendingAddOn => pendingAddOn.code !== addOn.addOnCode
|
||||
)
|
||||
}
|
||||
displayPrice={addOnsDisplayPrices[addOn.addOnCode]}
|
||||
nextBillingDate={subscription.payment.nextPaymentDueDate}
|
||||
/>
|
||||
))}
|
||||
{hasAiAssistViaWritefull && (
|
||||
<WritefullGrantedAddOn
|
||||
handleManageOnWritefull={handleManageOnWritefull}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p>{t('you_dont_have_any_add_ons_on_your_account')}</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddOns
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Plan } from '../../../../../../../../../types/subscription/plan'
|
||||
import { postJSON } from '../../../../../../../infrastructure/fetch-json'
|
||||
import LoadingSpinner from '../../../../../../../shared/components/loading-spinner'
|
||||
import useAsync from '../../../../../../../shared/hooks/use-async'
|
||||
import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
|
||||
import {
|
||||
cancelSubscriptionUrl,
|
||||
redirectAfterCancelSubscriptionUrl,
|
||||
} from '../../../../../data/subscription-url'
|
||||
import showDowngradeOption from '../../../../../util/show-downgrade-option'
|
||||
import GenericErrorAlert from '../../../generic-error-alert'
|
||||
import DowngradePlanButton from './downgrade-plan-button'
|
||||
import ExtendTrialButton from './extend-trial-button'
|
||||
import { useLocation } from '../../../../../../../shared/hooks/use-location'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
const planCodeToDowngradeTo = 'paid-personal'
|
||||
|
||||
function ConfirmCancelSubscriptionButton({
|
||||
showNoThanks,
|
||||
onClick,
|
||||
disabled,
|
||||
isLoading,
|
||||
}: {
|
||||
showNoThanks: boolean
|
||||
onClick: () => void
|
||||
disabled: boolean
|
||||
isLoading: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const text = showNoThanks ? t('no_thanks_cancel_now') : t('cancel_my_account')
|
||||
return (
|
||||
<OLButton
|
||||
isLoading={isLoading}
|
||||
loadingLabel={t('processing_uppercase') + '…'}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
variant={showNoThanks ? 'link' : undefined}
|
||||
>
|
||||
{text}
|
||||
</OLButton>
|
||||
)
|
||||
}
|
||||
|
||||
function NotCancelOption({
|
||||
isButtonDisabled,
|
||||
isLoadingSecondaryAction,
|
||||
isSuccessSecondaryAction,
|
||||
planToDowngradeTo,
|
||||
showExtendFreeTrial,
|
||||
showDowngrade,
|
||||
runAsyncSecondaryAction,
|
||||
}: {
|
||||
isButtonDisabled: boolean
|
||||
isLoadingSecondaryAction: boolean
|
||||
isSuccessSecondaryAction: boolean
|
||||
planToDowngradeTo?: Plan
|
||||
showExtendFreeTrial: boolean
|
||||
showDowngrade: boolean
|
||||
runAsyncSecondaryAction: (promise: Promise<unknown>) => Promise<unknown>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { setShowCancellation } = useSubscriptionDashboardContext()
|
||||
|
||||
if (showExtendFreeTrial) {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="have_more_days_to_try"
|
||||
values={{
|
||||
days: 14,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<ExtendTrialButton
|
||||
isButtonDisabled={isButtonDisabled}
|
||||
isLoading={isLoadingSecondaryAction || isSuccessSecondaryAction}
|
||||
runAsyncSecondaryAction={runAsyncSecondaryAction}
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (showDowngrade && planToDowngradeTo) {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="interested_in_cheaper_personal_plan"
|
||||
values={{
|
||||
price: planToDowngradeTo.displayPrice,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<DowngradePlanButton
|
||||
isButtonDisabled={isButtonDisabled}
|
||||
isLoading={isLoadingSecondaryAction || isSuccessSecondaryAction}
|
||||
planToDowngradeTo={planToDowngradeTo}
|
||||
runAsyncSecondaryAction={runAsyncSecondaryAction}
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function handleKeepPlan() {
|
||||
setShowCancellation(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<p>
|
||||
<OLButton variant="secondary" onClick={handleKeepPlan}>
|
||||
{t('i_want_to_stay')}
|
||||
</OLButton>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export function CancelSubscription() {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const { personalSubscription, plans, userCanExtendTrial } =
|
||||
useSubscriptionDashboardContext()
|
||||
const {
|
||||
isLoading: isLoadingCancel,
|
||||
isError: isErrorCancel,
|
||||
isSuccess: isSuccessCancel,
|
||||
runAsync: runAsyncCancel,
|
||||
} = useAsync()
|
||||
const {
|
||||
isLoading: isLoadingSecondaryAction,
|
||||
isError: isErrorSecondaryAction,
|
||||
isSuccess: isSuccessSecondaryAction,
|
||||
runAsync: runAsyncSecondaryAction,
|
||||
} = useAsync()
|
||||
const isButtonDisabled =
|
||||
isLoadingCancel ||
|
||||
isLoadingSecondaryAction ||
|
||||
isSuccessSecondaryAction ||
|
||||
isSuccessCancel
|
||||
|
||||
if (!personalSubscription || !('payment' in personalSubscription)) return null
|
||||
|
||||
const showDowngrade = showDowngradeOption(
|
||||
personalSubscription.plan.planCode,
|
||||
personalSubscription.plan.groupPlan,
|
||||
personalSubscription.payment.trialEndsAt,
|
||||
personalSubscription.payment.pausedAt,
|
||||
personalSubscription.payment.remainingPauseCycles
|
||||
)
|
||||
const planToDowngradeTo = plans.find(
|
||||
plan => plan.planCode === planCodeToDowngradeTo
|
||||
)
|
||||
if (showDowngrade && !planToDowngradeTo) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
|
||||
async function handleCancelSubscription() {
|
||||
try {
|
||||
await runAsyncCancel(postJSON(cancelSubscriptionUrl))
|
||||
location.assign(redirectAfterCancelSubscriptionUrl)
|
||||
} catch (e) {
|
||||
debugConsole.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const showExtendFreeTrial = userCanExtendTrial
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-center">
|
||||
<p>
|
||||
<strong>{t('wed_love_you_to_stay')}</strong>
|
||||
</p>
|
||||
|
||||
{(isErrorCancel || isErrorSecondaryAction) && <GenericErrorAlert />}
|
||||
|
||||
<NotCancelOption
|
||||
showExtendFreeTrial={showExtendFreeTrial}
|
||||
showDowngrade={showDowngrade}
|
||||
isButtonDisabled={isButtonDisabled}
|
||||
isLoadingSecondaryAction={isLoadingSecondaryAction}
|
||||
isSuccessSecondaryAction={isSuccessSecondaryAction}
|
||||
planToDowngradeTo={planToDowngradeTo}
|
||||
runAsyncSecondaryAction={runAsyncSecondaryAction}
|
||||
/>
|
||||
|
||||
<ConfirmCancelSubscriptionButton
|
||||
showNoThanks={showExtendFreeTrial || showDowngrade}
|
||||
onClick={handleCancelSubscription}
|
||||
disabled={isButtonDisabled}
|
||||
isLoading={isSuccessCancel || isLoadingCancel}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Plan } from '../../../../../../../../../types/subscription/plan'
|
||||
import { postJSON } from '../../../../../../../infrastructure/fetch-json'
|
||||
import { subscriptionUpdateUrl } from '../../../../../data/subscription-url'
|
||||
import { useLocation } from '../../../../../../../shared/hooks/use-location'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
export default function DowngradePlanButton({
|
||||
isButtonDisabled,
|
||||
isLoading,
|
||||
planToDowngradeTo,
|
||||
runAsyncSecondaryAction,
|
||||
}: {
|
||||
isButtonDisabled: boolean
|
||||
isLoading: boolean
|
||||
planToDowngradeTo: Plan
|
||||
runAsyncSecondaryAction: (promise: Promise<unknown>) => Promise<unknown>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const buttonText = t('yes_move_me_to_personal_plan')
|
||||
|
||||
async function handleDowngradePlan() {
|
||||
try {
|
||||
await runAsyncSecondaryAction(
|
||||
postJSON(`${subscriptionUpdateUrl}?downgradeToPaidPersonal`, {
|
||||
body: { plan_code: planToDowngradeTo.planCode },
|
||||
})
|
||||
)
|
||||
location.reload()
|
||||
} catch (e) {
|
||||
debugConsole.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={handleDowngradePlan}
|
||||
disabled={isButtonDisabled}
|
||||
isLoading={isLoading}
|
||||
loadingLabel={t('processing_uppercase') + '…'}
|
||||
>
|
||||
{buttonText}
|
||||
</OLButton>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { putJSON } from '../../../../../../../infrastructure/fetch-json'
|
||||
import { extendTrialUrl } from '../../../../../data/subscription-url'
|
||||
import { useLocation } from '../../../../../../../shared/hooks/use-location'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
export default function ExtendTrialButton({
|
||||
isButtonDisabled,
|
||||
isLoading,
|
||||
runAsyncSecondaryAction,
|
||||
}: {
|
||||
isButtonDisabled: boolean
|
||||
isLoading: boolean
|
||||
runAsyncSecondaryAction: (promise: Promise<unknown>) => Promise<unknown>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const buttonText = t('ill_take_it')
|
||||
const location = useLocation()
|
||||
|
||||
async function handleExtendTrial() {
|
||||
try {
|
||||
await runAsyncSecondaryAction(putJSON(extendTrialUrl))
|
||||
location.reload()
|
||||
} catch (e) {
|
||||
debugConsole.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={handleExtendTrial}
|
||||
disabled={isButtonDisabled}
|
||||
isLoading={isLoading}
|
||||
loadingLabel={t('processing_uppercase') + '…'}
|
||||
>
|
||||
{buttonText}
|
||||
</OLButton>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as eventTracking from '../../../../../../infrastructure/event-tracking'
|
||||
import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
|
||||
export function CancelSubscriptionButton() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
recurlyLoadError,
|
||||
personalSubscription,
|
||||
setModalIdShown,
|
||||
setShowCancellation,
|
||||
} = useSubscriptionDashboardContext()
|
||||
|
||||
const subscription = personalSubscription as PaidSubscription
|
||||
const isInTrial =
|
||||
subscription?.payment.trialEndsAtFormatted &&
|
||||
subscription?.payment.trialEndsAt &&
|
||||
new Date(subscription.payment.trialEndsAt).getTime() > Date.now()
|
||||
const hasPendingOrActivePause =
|
||||
subscription.payment.state === 'paused' ||
|
||||
(subscription.payment.state === 'active' &&
|
||||
subscription.payment.remainingPauseCycles &&
|
||||
subscription.payment.remainingPauseCycles > 0)
|
||||
const planIsEligibleForPause =
|
||||
!subscription.pendingPlan &&
|
||||
!subscription.groupPlan &&
|
||||
!isInTrial &&
|
||||
!subscription.planCode.includes('ann') &&
|
||||
!subscription.addOns?.length
|
||||
const enablePause =
|
||||
useFeatureFlag('pause-subscription') &&
|
||||
!hasPendingOrActivePause &&
|
||||
planIsEligibleForPause
|
||||
|
||||
function handleCancelSubscriptionClick() {
|
||||
eventTracking.sendMB('subscription-page-cancel-button-click', {
|
||||
plan_code: subscription?.planCode,
|
||||
is_trial: isInTrial,
|
||||
})
|
||||
if (enablePause) setModalIdShown('pause-subscription')
|
||||
else setShowCancellation(true)
|
||||
}
|
||||
|
||||
if (recurlyLoadError) return null
|
||||
|
||||
return (
|
||||
<OLButton variant="danger-ghost" onClick={handleCancelSubscriptionClick}>
|
||||
{t('cancel_your_subscription')}
|
||||
</OLButton>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
|
||||
import isInFreeTrial from '../../../../../util/is-in-free-trial'
|
||||
import { PaidSubscription } from '../../../../../../../../../types/subscription/dashboard/subscription'
|
||||
|
||||
export function ChangeToGroupPlan() {
|
||||
const { t } = useTranslation()
|
||||
const { handleOpenModal, personalSubscription } =
|
||||
useSubscriptionDashboardContext()
|
||||
|
||||
// TODO: Better way to get PaidSubscription/trialEndsAt
|
||||
const subscription =
|
||||
personalSubscription && 'payment' in personalSubscription
|
||||
? (personalSubscription as PaidSubscription)
|
||||
: null
|
||||
|
||||
const handleClick = () => {
|
||||
handleOpenModal('change-to-group')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-gray text-center mt-3 p-3">
|
||||
<h2 style={{ marginTop: 0 }}>{t('looking_multiple_licenses')}</h2>
|
||||
<p style={{ margin: 0 }}>{t('reduce_costs_group_licenses')}</p>
|
||||
<br />
|
||||
{isInFreeTrial(subscription?.payment?.trialEndsAt) ? (
|
||||
<>
|
||||
<OLTooltip
|
||||
id="disabled-change-to-group-plan"
|
||||
description={t(
|
||||
'sorry_you_can_only_change_to_group_from_trial_via_support'
|
||||
)}
|
||||
overlayProps={{ placement: 'top' }}
|
||||
>
|
||||
<div>
|
||||
<OLButton variant="primary" disabled>
|
||||
{t('change_to_group_plan')}
|
||||
</OLButton>
|
||||
</div>
|
||||
</OLTooltip>
|
||||
</>
|
||||
) : (
|
||||
<OLButton variant="primary" onClick={handleClick}>
|
||||
{t('change_to_group_plan')}
|
||||
</OLButton>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Plan } from '../../../../../../../../../types/subscription/plan'
|
||||
import Icon from '../../../../../../../shared/components/icon'
|
||||
import { useSubscriptionDashboardContext } from '../../../../../context/subscription-dashboard-context'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
function ChangeToPlanButton({ planCode }: { planCode: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { handleOpenModal } = useSubscriptionDashboardContext()
|
||||
|
||||
const handleClick = () => {
|
||||
handleOpenModal('change-to-plan', planCode)
|
||||
}
|
||||
|
||||
return (
|
||||
<OLButton variant="primary" onClick={handleClick}>
|
||||
{t('change_to_this_plan')}
|
||||
</OLButton>
|
||||
)
|
||||
}
|
||||
|
||||
function KeepCurrentPlanButton({ plan }: { plan: Plan }) {
|
||||
const { t } = useTranslation()
|
||||
const { handleOpenModal } = useSubscriptionDashboardContext()
|
||||
|
||||
const handleClick = () => {
|
||||
handleOpenModal('keep-current-plan')
|
||||
}
|
||||
|
||||
return (
|
||||
<OLButton variant="primary" onClick={handleClick}>
|
||||
{t('keep_current_plan')}
|
||||
</OLButton>
|
||||
)
|
||||
}
|
||||
|
||||
function ChangePlanButton({ plan }: { plan: Plan }) {
|
||||
const { t } = useTranslation()
|
||||
const { personalSubscription } = useSubscriptionDashboardContext()
|
||||
const isCurrentPlanForUser =
|
||||
personalSubscription?.planCode &&
|
||||
plan.planCode === personalSubscription.planCode.split('_')[0]
|
||||
|
||||
if (isCurrentPlanForUser && personalSubscription.pendingPlan) {
|
||||
return <KeepCurrentPlanButton plan={plan} />
|
||||
} else if (isCurrentPlanForUser && !personalSubscription.pendingPlan) {
|
||||
return (
|
||||
<b>
|
||||
<Icon type="check" /> {t('your_plan')}
|
||||
</b>
|
||||
)
|
||||
} else if (
|
||||
personalSubscription?.pendingPlan?.planCode?.split('_')[0] === plan.planCode
|
||||
) {
|
||||
return (
|
||||
<b>
|
||||
<Icon type="check" /> {t('your_new_plan')}
|
||||
</b>
|
||||
)
|
||||
} else {
|
||||
return <ChangeToPlanButton planCode={plan.planCode} />
|
||||
}
|
||||
}
|
||||
|
||||
function PlansRow({ plan }: { plan: Plan }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className="align-middle">
|
||||
<strong>{plan.name}</strong>
|
||||
</td>
|
||||
<td className="align-middle">
|
||||
{plan.displayPrice} / {plan.annual ? t('year') : t('month')}
|
||||
</td>
|
||||
<td className="align-middle text-center">
|
||||
<ChangePlanButton plan={plan} />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function PlansRows({ plans }: { plans: Array<Plan> }) {
|
||||
return (
|
||||
<>
|
||||
{plans &&
|
||||
plans.map(plan => (
|
||||
<PlansRow key={`plans-row-${plan.planCode}`} plan={plan} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function IndividualPlansTable({ plans }: { plans: Array<Plan> }) {
|
||||
const { t } = useTranslation()
|
||||
const { recurlyLoadError } = useSubscriptionDashboardContext()
|
||||
|
||||
const filteredPlans = useMemo(
|
||||
() =>
|
||||
plans?.filter(
|
||||
plan =>
|
||||
!['paid-personal', 'paid-personal-annual'].includes(plan.planCode)
|
||||
),
|
||||
[plans]
|
||||
)
|
||||
|
||||
if (!filteredPlans || recurlyLoadError) return null
|
||||
|
||||
return (
|
||||
<table className="table align-middle table-vertically-centered-cells m-0">
|
||||
<thead>
|
||||
<tr className="d-none d-md-table-row">
|
||||
<th>{t('name')}</th>
|
||||
<th>{t('price')}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<PlansRows plans={filteredPlans} />
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
|
||||
import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
|
||||
import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
|
||||
import { useLocation } from '../../../../../../../../shared/hooks/use-location'
|
||||
import OLModal, {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
import {
|
||||
AI_ADD_ON_CODE,
|
||||
ADD_ON_NAME,
|
||||
isStandaloneAiPlanCode,
|
||||
} from '../../../../../../data/add-on-codes'
|
||||
import {
|
||||
cancelSubscriptionUrl,
|
||||
redirectAfterCancelSubscriptionUrl,
|
||||
} from '../../../../../../data/subscription-url'
|
||||
|
||||
export function CancelAiAddOnModal() {
|
||||
const modalId: SubscriptionDashModalIds = 'cancel-ai-add-on'
|
||||
const [error, setError] = useState(false)
|
||||
const [inflight, setInflight] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const { handleCloseModal, modalIdShown, personalSubscription } =
|
||||
useSubscriptionDashboardContext()
|
||||
const location = useLocation()
|
||||
|
||||
if (!personalSubscription) return null
|
||||
|
||||
const onStandalone = isStandaloneAiPlanCode(personalSubscription.planCode)
|
||||
|
||||
const cancellationEndpoint = onStandalone
|
||||
? cancelSubscriptionUrl
|
||||
: `/user/subscription/addon/${AI_ADD_ON_CODE}/remove`
|
||||
|
||||
async function handleConfirmChange() {
|
||||
setError(false)
|
||||
setInflight(true)
|
||||
|
||||
try {
|
||||
await postJSON(cancellationEndpoint)
|
||||
location.assign(redirectAfterCancelSubscriptionUrl)
|
||||
} catch (e) {
|
||||
setError(true)
|
||||
setInflight(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (modalIdShown !== modalId) return null
|
||||
|
||||
return (
|
||||
<OLModal
|
||||
id={modalId}
|
||||
show
|
||||
animation
|
||||
onHide={handleCloseModal}
|
||||
backdrop="static"
|
||||
>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>{t('cancel_add_on')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
{error && (
|
||||
<OLNotification
|
||||
type="error"
|
||||
aria-live="polite"
|
||||
content={
|
||||
<>
|
||||
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
|
||||
{t('generic_if_problem_continues_contact_us')}.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="are_you_sure_you_want_to_cancel_add_on"
|
||||
values={{
|
||||
addOnName: ADD_ON_NAME,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
<p>{t('the_add_on_will_remain_active_until')}</p>
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
disabled={inflight}
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
{t('back')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="danger"
|
||||
disabled={inflight}
|
||||
isLoading={inflight}
|
||||
loadingLabel={t('processing_uppercase') + '…'}
|
||||
onClick={handleConfirmChange}
|
||||
>
|
||||
{t('cancel_add_on')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
|
||||
import LoadingSpinner from '../../../../../../../../shared/components/loading-spinner'
|
||||
import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
|
||||
import { ChangeToGroupPlan } from '../change-to-group-plan'
|
||||
import { IndividualPlansTable } from '../individual-plans-table'
|
||||
import OLModal, {
|
||||
OLModalBody,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
|
||||
function ChangePlanOptions() {
|
||||
const { plans, queryingIndividualPlansData, recurlyLoadError } =
|
||||
useSubscriptionDashboardContext()
|
||||
|
||||
if (!plans || recurlyLoadError) return null
|
||||
|
||||
if (queryingIndividualPlansData) {
|
||||
return (
|
||||
<>
|
||||
<LoadingSpinner />
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<div className="border rounded px-2 pt-1 table-outlined-container">
|
||||
<IndividualPlansTable plans={plans} />
|
||||
</div>
|
||||
<ChangeToGroupPlan />
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function ChangePlanModal() {
|
||||
const modalId: SubscriptionDashModalIds = 'change-plan'
|
||||
const { t } = useTranslation()
|
||||
const { handleCloseModal, modalIdShown } = useSubscriptionDashboardContext()
|
||||
|
||||
if (modalIdShown !== modalId) return null
|
||||
|
||||
return (
|
||||
<OLModal id={modalId} show animation onHide={handleCloseModal} size="lg">
|
||||
<OLModalHeader closeButton>
|
||||
<OLModalTitle>{t('change_plan')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
<ChangePlanOptions />
|
||||
</OLModalBody>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import { PaidSubscription } from '../../../../../../../../../../types/subscription/dashboard/subscription'
|
||||
import { PriceForDisplayData } from '../../../../../../../../../../types/subscription/plan'
|
||||
import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
|
||||
import getMeta from '../../../../../../../../utils/meta'
|
||||
import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
|
||||
import GenericErrorAlert from '../../../../generic-error-alert'
|
||||
import { subscriptionUpdateUrl } from '../../../../../../data/subscription-url'
|
||||
import { getRecurlyGroupPlanCode } from '../../../../../../util/recurly-group-plan-code'
|
||||
import { useLocation } from '../../../../../../../../shared/hooks/use-location'
|
||||
import OLModal, {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
|
||||
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
|
||||
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
|
||||
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
|
||||
import { useContactUsModal } from '@/shared/hooks/use-contact-us-modal'
|
||||
import { UserProvider } from '@/shared/context/user-context'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
|
||||
const educationalPercentDiscount = 40
|
||||
|
||||
function GroupPlanCollaboratorCount({ planCode }: { planCode: string }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (planCode === 'collaborator') {
|
||||
return (
|
||||
<>
|
||||
{t('collabs_per_proj', {
|
||||
collabcount: 10,
|
||||
})}
|
||||
</>
|
||||
)
|
||||
} else if (planCode === 'professional') {
|
||||
return <>{t('unlimited_collabs')}</>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function GroupPrice({
|
||||
groupPlanToChangeToPrice,
|
||||
queryingGroupPlanToChangeToPrice,
|
||||
}: {
|
||||
groupPlanToChangeToPrice?: PriceForDisplayData
|
||||
queryingGroupPlanToChangeToPrice: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const totalPrice =
|
||||
!queryingGroupPlanToChangeToPrice &&
|
||||
groupPlanToChangeToPrice?.totalForDisplay
|
||||
? groupPlanToChangeToPrice.totalForDisplay
|
||||
: '…'
|
||||
|
||||
const perUserPrice =
|
||||
!queryingGroupPlanToChangeToPrice &&
|
||||
groupPlanToChangeToPrice?.perUserDisplayPrice
|
||||
? groupPlanToChangeToPrice.perUserDisplayPrice
|
||||
: '…'
|
||||
|
||||
return (
|
||||
<>
|
||||
<span aria-hidden>
|
||||
{totalPrice} <span className="small">/ {t('year')}</span>
|
||||
</span>
|
||||
<span className="sr-only">
|
||||
{queryingGroupPlanToChangeToPrice
|
||||
? t('loading_prices')
|
||||
: t('x_price_per_year', {
|
||||
price: groupPlanToChangeToPrice?.totalForDisplay,
|
||||
})}
|
||||
</span>
|
||||
|
||||
<span className="circle-subtext">
|
||||
<span aria-hidden>
|
||||
{t('x_price_per_user', {
|
||||
price: perUserPrice,
|
||||
})}
|
||||
</span>
|
||||
<span className="sr-only">
|
||||
{queryingGroupPlanToChangeToPrice
|
||||
? t('loading_prices')
|
||||
: t('x_price_per_user', {
|
||||
price: perUserPrice,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChangeToGroupModal() {
|
||||
const modalId = 'change-to-group'
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
groupPlanToChangeToCode,
|
||||
groupPlanToChangeToPrice,
|
||||
groupPlanToChangeToPriceError,
|
||||
groupPlanToChangeToSize,
|
||||
groupPlanToChangeToUsage,
|
||||
handleCloseModal,
|
||||
modalIdShown,
|
||||
queryingGroupPlanToChangeToPrice,
|
||||
setGroupPlanToChangeToCode,
|
||||
setGroupPlanToChangeToSize,
|
||||
setGroupPlanToChangeToUsage,
|
||||
} = useSubscriptionDashboardContext()
|
||||
const { modal: contactModal, showModal: showContactModal } =
|
||||
useContactUsModal({ autofillProjectUrl: false })
|
||||
const groupPlans = getMeta('ol-groupPlans')
|
||||
const showGroupDiscount = getMeta('ol-showGroupDiscount')
|
||||
const personalSubscription = getMeta('ol-subscription') as PaidSubscription
|
||||
const [error, setError] = useState(false)
|
||||
const [inflight, setInflight] = useState(false)
|
||||
const location = useLocation()
|
||||
|
||||
async function upgrade() {
|
||||
setError(false)
|
||||
setInflight(true)
|
||||
|
||||
try {
|
||||
await postJSON(subscriptionUpdateUrl, {
|
||||
body: {
|
||||
plan_code: getRecurlyGroupPlanCode(
|
||||
groupPlanToChangeToCode,
|
||||
groupPlanToChangeToSize,
|
||||
groupPlanToChangeToUsage
|
||||
),
|
||||
},
|
||||
})
|
||||
location.reload()
|
||||
} catch (e) {
|
||||
setError(true)
|
||||
setInflight(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (personalSubscription.plan.planCode.includes('professional')) {
|
||||
setGroupPlanToChangeToCode('professional')
|
||||
}
|
||||
}, [personalSubscription, setGroupPlanToChangeToCode])
|
||||
|
||||
if (
|
||||
modalIdShown !== modalId ||
|
||||
!groupPlans ||
|
||||
!groupPlans.plans ||
|
||||
!groupPlans.sizes ||
|
||||
!groupPlanToChangeToCode
|
||||
)
|
||||
return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserProvider>{contactModal}</UserProvider>
|
||||
<OLModal
|
||||
id={modalId}
|
||||
show
|
||||
animation
|
||||
onHide={handleCloseModal}
|
||||
backdrop="static"
|
||||
>
|
||||
<OLModalHeader closeButton>
|
||||
<OLModalTitle className="lh-sm">
|
||||
{t('customize_your_group_subscription')}
|
||||
{showGroupDiscount && (
|
||||
<p className="group-subscription-modal-title-discount">
|
||||
{t('save_x_or_more', { percentage: '10%' })}
|
||||
</p>
|
||||
)}
|
||||
</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
<div className="container-fluid plans group-subscription-modal">
|
||||
{groupPlanToChangeToPriceError && <GenericErrorAlert />}
|
||||
<div className="row">
|
||||
<div className="col-md-6 text-center">
|
||||
<div className="circle circle-lg mb-4 mx-auto">
|
||||
<GroupPrice
|
||||
groupPlanToChangeToPrice={groupPlanToChangeToPrice}
|
||||
queryingGroupPlanToChangeToPrice={
|
||||
queryingGroupPlanToChangeToPrice
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p>{t('each_user_will_have_access_to')}:</p>
|
||||
<ul className="list-unstyled">
|
||||
<li className="mb-3">
|
||||
<strong>
|
||||
<GroupPlanCollaboratorCount
|
||||
planCode={groupPlanToChangeToCode}
|
||||
/>
|
||||
</strong>
|
||||
</li>
|
||||
<li>
|
||||
<strong>{t('all_premium_features')}</strong>
|
||||
</li>
|
||||
<li>{t('sync_dropbox_github')}</li>
|
||||
<li>{t('full_doc_history')}</li>
|
||||
<li>{t('track_changes')}</li>
|
||||
<li>
|
||||
<span aria-hidden>+ {t('more').toLowerCase()}</span>
|
||||
<span className="sr-only">{t('plus_more')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<form className="form">
|
||||
<fieldset className="form-group">
|
||||
<legend className="legend-as-label">{t('plan')}</legend>
|
||||
{groupPlans.plans.map(option => (
|
||||
<div key={option.code}>
|
||||
<OLFormCheckbox
|
||||
type="radio"
|
||||
name="plan-code"
|
||||
value={option.code}
|
||||
id={`plan-option-${option.code}`}
|
||||
onChange={() =>
|
||||
setGroupPlanToChangeToCode(option.code)
|
||||
}
|
||||
checked={option.code === groupPlanToChangeToCode}
|
||||
label={option.display}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<OLFormGroup controlId="size">
|
||||
<OLFormLabel>{t('number_of_users')}</OLFormLabel>
|
||||
<OLFormSelect
|
||||
name="size"
|
||||
value={groupPlanToChangeToSize}
|
||||
onChange={e => setGroupPlanToChangeToSize(e.target.value)}
|
||||
>
|
||||
{groupPlans.sizes.map(size => (
|
||||
<option key={`size-option-${size}`}>{size}</option>
|
||||
))}
|
||||
</OLFormSelect>
|
||||
</OLFormGroup>
|
||||
|
||||
<OLFormCheckbox
|
||||
id="usage"
|
||||
type="checkbox"
|
||||
autoComplete="off"
|
||||
checked={groupPlanToChangeToUsage === 'educational'}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setGroupPlanToChangeToUsage('educational')
|
||||
} else {
|
||||
setGroupPlanToChangeToUsage('enterprise')
|
||||
}
|
||||
}}
|
||||
label={t(
|
||||
'apply_educational_discount_description_with_group_discount'
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="educational-discount-badge pt-4 text-center">
|
||||
{groupPlanToChangeToUsage === 'educational' && (
|
||||
<p className="applied">
|
||||
{t('educational_percent_discount_applied', {
|
||||
percent: educationalPercentDiscount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<div className="text-center">
|
||||
{groupPlanToChangeToPrice?.includesTax && (
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="total_with_subtotal_and_tax"
|
||||
values={{
|
||||
total: groupPlanToChangeToPrice.totalForDisplay,
|
||||
subtotal: groupPlanToChangeToPrice.subtotal,
|
||||
tax: groupPlanToChangeToPrice.tax,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
/* eslint-disable-next-line react/jsx-key */
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<strong>
|
||||
{t('new_subscription_will_be_billed_immediately')}
|
||||
</strong>
|
||||
</p>
|
||||
<hr className="thin my-3" />
|
||||
{error && (
|
||||
<OLNotification
|
||||
type="error"
|
||||
aria-live="polite"
|
||||
content={
|
||||
<>
|
||||
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
|
||||
{t('generic_if_problem_continues_contact_us')}.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<OLButton
|
||||
variant="primary"
|
||||
size="lg"
|
||||
disabled={
|
||||
queryingGroupPlanToChangeToPrice ||
|
||||
!groupPlanToChangeToPrice ||
|
||||
inflight
|
||||
}
|
||||
onClick={upgrade}
|
||||
isLoading={inflight}
|
||||
loadingLabel={t('processing_uppercase') + '…'}
|
||||
>
|
||||
{t('upgrade_now')}
|
||||
</OLButton>
|
||||
<hr className="thin my-3" />
|
||||
<OLButton
|
||||
variant="link"
|
||||
className="btn-inline-link"
|
||||
onClick={showContactModal}
|
||||
>
|
||||
{t('need_more_than_x_licenses', {
|
||||
x: 20,
|
||||
})}{' '}
|
||||
{t('please_get_in_touch')}
|
||||
</OLButton>
|
||||
</div>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
|
||||
import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
|
||||
import getMeta from '../../../../../../../../utils/meta'
|
||||
import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
|
||||
import { subscriptionUpdateUrl } from '../../../../../../data/subscription-url'
|
||||
import { useLocation } from '../../../../../../../../shared/hooks/use-location'
|
||||
import OLModal, {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
|
||||
export function ConfirmChangePlanModal() {
|
||||
const modalId: SubscriptionDashModalIds = 'change-to-plan'
|
||||
const [error, setError] = useState(false)
|
||||
const [inflight, setInflight] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const { handleCloseModal, modalIdShown, plans, planCodeToChangeTo } =
|
||||
useSubscriptionDashboardContext()
|
||||
const planCodesChangingAtTermEnd = getMeta('ol-planCodesChangingAtTermEnd')
|
||||
const location = useLocation()
|
||||
|
||||
async function handleConfirmChange() {
|
||||
setError(false)
|
||||
setInflight(true)
|
||||
|
||||
try {
|
||||
await postJSON(`${subscriptionUpdateUrl}?origin=confirmChangePlan`, {
|
||||
body: {
|
||||
plan_code: planCodeToChangeTo,
|
||||
},
|
||||
})
|
||||
location.reload()
|
||||
} catch (e) {
|
||||
setError(true)
|
||||
setInflight(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (modalIdShown !== modalId || !planCodeToChangeTo) return null
|
||||
|
||||
const plan = plans.find(p => p.planCode === planCodeToChangeTo)
|
||||
if (!plan) return null
|
||||
|
||||
const planWillChangeAtTermEnd =
|
||||
planCodesChangingAtTermEnd &&
|
||||
planCodesChangingAtTermEnd.indexOf(planCodeToChangeTo) > -1
|
||||
|
||||
return (
|
||||
<OLModal
|
||||
id={modalId}
|
||||
show
|
||||
animation
|
||||
onHide={handleCloseModal}
|
||||
backdrop="static"
|
||||
>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>{t('change_plan')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
{error && (
|
||||
<OLNotification
|
||||
type="error"
|
||||
aria-live="polite"
|
||||
content={
|
||||
<>
|
||||
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
|
||||
{t('generic_if_problem_continues_contact_us')}.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="sure_you_want_to_change_plan"
|
||||
values={{
|
||||
planName: plan.name,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
{planWillChangeAtTermEnd && (
|
||||
<>
|
||||
<p>{t('existing_plan_active_until_term_end')}</p>
|
||||
<p>{t('want_change_to_apply_before_plan_end')}</p>
|
||||
</>
|
||||
)}
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
disabled={inflight}
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
disabled={inflight}
|
||||
isLoading={inflight}
|
||||
loadingLabel={t('processing_uppercase') + '…'}
|
||||
onClick={handleConfirmChange}
|
||||
>
|
||||
{t('change_plan')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
|
||||
import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
|
||||
import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
|
||||
import { cancelPendingSubscriptionChangeUrl } from '../../../../../../data/subscription-url'
|
||||
import { useLocation } from '../../../../../../../../shared/hooks/use-location'
|
||||
import OLModal, {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
|
||||
export function KeepCurrentPlanModal() {
|
||||
const modalId: SubscriptionDashModalIds = 'keep-current-plan'
|
||||
const [error, setError] = useState(false)
|
||||
const [inflight, setInflight] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const { modalIdShown, handleCloseModal, personalSubscription } =
|
||||
useSubscriptionDashboardContext()
|
||||
|
||||
async function confirmCancelPendingPlanChange() {
|
||||
setError(false)
|
||||
setInflight(true)
|
||||
|
||||
try {
|
||||
await postJSON(cancelPendingSubscriptionChangeUrl)
|
||||
location.reload()
|
||||
} catch (e) {
|
||||
setError(true)
|
||||
setInflight(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (modalIdShown !== modalId || !personalSubscription) return null
|
||||
|
||||
return (
|
||||
<OLModal
|
||||
id={modalId}
|
||||
show
|
||||
animation
|
||||
onHide={handleCloseModal}
|
||||
backdrop="static"
|
||||
>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>{t('change_plan')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
{error && (
|
||||
<OLNotification
|
||||
type="error"
|
||||
aria-live="polite"
|
||||
content={
|
||||
<>
|
||||
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
|
||||
{t('generic_if_problem_continues_contact_us')}.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="sure_you_want_to_cancel_plan_change"
|
||||
values={{
|
||||
planName: personalSubscription.plan.name,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
disabled={inflight}
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
disabled={inflight}
|
||||
isLoading={inflight}
|
||||
loadingLabel={t('processing_uppercase') + '…'}
|
||||
onClick={confirmCancelPendingPlanChange}
|
||||
>
|
||||
{t('revert_pending_plan_change')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SubscriptionDashModalIds } from '../../../../../../../../../../types/subscription/dashboard/modal-ids'
|
||||
import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
|
||||
import OLModal, {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
export function WritefullBundleManagementModal() {
|
||||
const modalId: SubscriptionDashModalIds = 'manage-on-writefull'
|
||||
const { t } = useTranslation()
|
||||
const { handleCloseModal, modalIdShown } = useSubscriptionDashboardContext()
|
||||
|
||||
if (modalIdShown !== modalId) return null
|
||||
|
||||
return (
|
||||
<OLModal
|
||||
id={modalId}
|
||||
show
|
||||
animation
|
||||
onHide={handleCloseModal}
|
||||
backdrop="static"
|
||||
>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>{t('manage_your_ai_assist_add_on')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
<p>{t('ai_assist_in_overleaf_is_included_via_writefull')}</p>
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<OLButton variant="secondary" onClick={handleCloseModal}>
|
||||
{t('back')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={handleCloseModal}
|
||||
href="https://my.writefull.com/account"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t('go_to_writefull')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react'
|
||||
import { SubscriptionDashModalIds } from '../../../../../../../../types/subscription/dashboard/modal-ids'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useSubscriptionDashboardContext } from '@/features/subscription/context/subscription-dashboard-context'
|
||||
import OLModal, {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import { useLocation } from '@/shared/hooks/use-location'
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
|
||||
|
||||
export function ConfirmUnpauseSubscriptionModal() {
|
||||
const modalId: SubscriptionDashModalIds = 'unpause-subscription'
|
||||
const [error, setError] = useState(false)
|
||||
const [inflight, setInflight] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const { handleCloseModal, modalIdShown, personalSubscription } =
|
||||
useSubscriptionDashboardContext()
|
||||
const location = useLocation()
|
||||
const subscription = personalSubscription as PaidSubscription
|
||||
|
||||
async function handleConfirmUnpause() {
|
||||
setError(false)
|
||||
setInflight(true)
|
||||
try {
|
||||
await postJSON('/user/subscription/resume')
|
||||
const newUrl = new URL(location.toString())
|
||||
newUrl.searchParams.set('flash', 'unpaused')
|
||||
window.history.replaceState(null, '', newUrl)
|
||||
location.reload()
|
||||
} catch (e) {
|
||||
setError(true)
|
||||
setInflight(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (modalIdShown !== modalId) return null
|
||||
|
||||
return (
|
||||
<OLModal
|
||||
id={modalId}
|
||||
show
|
||||
animation
|
||||
onHide={handleCloseModal}
|
||||
backdrop="static"
|
||||
>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>{t('pick_up_where_you_left_off')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
{error && (
|
||||
<OLNotification
|
||||
type="error"
|
||||
aria-live="polite"
|
||||
content={
|
||||
<>
|
||||
{t('generic_something_went_wrong')}. {t('try_again')}.{' '}
|
||||
{t('generic_if_problem_continues_contact_us')}.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="lets_get_those_premium_features"
|
||||
values={{
|
||||
paymentAmount: subscription.payment.displayPrice,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
disabled={inflight}
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
disabled={inflight}
|
||||
isLoading={inflight}
|
||||
onClick={handleConfirmUnpause}
|
||||
>
|
||||
{t('unpause_subscription')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Trans } from 'react-i18next'
|
||||
|
||||
export function ContactSupportToChangeGroupPlan() {
|
||||
return (
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="contact_support_to_change_group_subscription"
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
|
||||
<a href="/contact" />,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useSubscriptionDashboardContext } from '@/features/subscription/context/subscription-dashboard-context'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLocation } from '@/shared/hooks/use-location'
|
||||
|
||||
export type FlashMessageName = 'paused' | 'unpaused' | 'error'
|
||||
|
||||
export function FlashMessage() {
|
||||
const { t } = useTranslation()
|
||||
const { personalSubscription } = useSubscriptionDashboardContext()
|
||||
const location = useLocation()
|
||||
const [message] = useState(
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
new URL(window.location.toString()).searchParams.get(
|
||||
'flash'
|
||||
) as FlashMessageName
|
||||
)
|
||||
const subscription = personalSubscription as PaidSubscription
|
||||
useEffect(() => {
|
||||
// clear any flash message IDs so they only show once
|
||||
if (location.toString()) {
|
||||
const newUrl = new URL(location.toString())
|
||||
newUrl.searchParams.delete('flash')
|
||||
window.history.replaceState(null, '', newUrl)
|
||||
}
|
||||
}, [location])
|
||||
|
||||
switch (message) {
|
||||
case 'paused':
|
||||
return (
|
||||
<Notification
|
||||
type="success"
|
||||
content={
|
||||
<Trans
|
||||
i18nKey="your_subscription_will_pause_on_short"
|
||||
values={{
|
||||
pauseDate: subscription.payment.nextPaymentDueAt,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
case 'unpaused':
|
||||
return (
|
||||
<Notification
|
||||
type="success"
|
||||
content={t('you_unpaused_your_subscription')}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return <></>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
|
||||
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
|
||||
import { CancelSubscriptionButton } from './cancel-subscription-button'
|
||||
import { CancelSubscription } from './cancel-plan/cancel-subscription'
|
||||
import { ChangePlanModal } from './change-plan/modals/change-plan-modal'
|
||||
import { ConfirmChangePlanModal } from './change-plan/modals/confirm-change-plan-modal'
|
||||
import { KeepCurrentPlanModal } from './change-plan/modals/keep-current-plan-modal'
|
||||
import { ChangeToGroupModal } from './change-plan/modals/change-to-group-modal'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import PauseSubscriptionModal from '../../pause-modal'
|
||||
import { ConfirmUnpauseSubscriptionModal } from './confirm-unpause-modal'
|
||||
|
||||
export function PausedSubscription({
|
||||
subscription,
|
||||
}: {
|
||||
subscription: PaidSubscription
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
recurlyLoadError,
|
||||
setModalIdShown,
|
||||
showCancellation,
|
||||
getFormattedRenewalDate,
|
||||
} = useSubscriptionDashboardContext()
|
||||
|
||||
if (showCancellation) return <CancelSubscription />
|
||||
|
||||
const handleUnpauseClick = async () => {
|
||||
setModalIdShown('unpause-subscription')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="youve_paused_your_subscription"
|
||||
values={{
|
||||
planName: subscription.plan.name,
|
||||
reactivationDate: getFormattedRenewalDate(),
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
<p>{t('until_then_you_can_still')}</p>
|
||||
<ul>
|
||||
<li>{t('access_edit_your_projects')}</li>
|
||||
<li>{t('continue_using_free_features')}</li>
|
||||
</ul>
|
||||
<p>{t('well_be_here_when_youre_ready')}</p>
|
||||
<p>
|
||||
<OLButton variant="primary" onClick={handleUnpauseClick}>
|
||||
{t('unpause_subscription')}
|
||||
</OLButton>
|
||||
{!recurlyLoadError && (
|
||||
<>
|
||||
{' '}
|
||||
<CancelSubscriptionButton />
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<p className="d-inline-flex flex-wrap gap-1">
|
||||
<a
|
||||
href={subscription.payment.billingDetailsLink}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="btn btn-secondary-info btn-secondary"
|
||||
>
|
||||
{t('update_your_billing_details')}
|
||||
</a>{' '}
|
||||
<a
|
||||
href={subscription.payment.accountManagementLink}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="btn btn-secondary-info btn-secondary"
|
||||
>
|
||||
{t('view_your_invoices')}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<ChangePlanModal />
|
||||
<ConfirmChangePlanModal />
|
||||
<KeepCurrentPlanModal />
|
||||
<ChangeToGroupModal />
|
||||
<PauseSubscriptionModal />
|
||||
<ConfirmUnpauseSubscriptionModal />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Trans } from 'react-i18next'
|
||||
|
||||
export function PendingAdditionalLicenses({
|
||||
additionalLicenses,
|
||||
totalLicenses,
|
||||
}: {
|
||||
additionalLicenses: number
|
||||
totalLicenses: number
|
||||
}) {
|
||||
return (
|
||||
<Trans
|
||||
i18nKey="additional_licenses"
|
||||
values={{
|
||||
additionalLicenses,
|
||||
totalLicenses,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Trans } from 'react-i18next'
|
||||
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
|
||||
import { PendingPaymentProviderPlan } from '../../../../../../../../types/subscription/plan'
|
||||
import { AI_ADD_ON_CODE, ADD_ON_NAME } from '../../../../data/add-on-codes'
|
||||
|
||||
export function PendingPlanChange({
|
||||
subscription,
|
||||
}: {
|
||||
subscription: PaidSubscription
|
||||
}) {
|
||||
if (!subscription.pendingPlan) return null
|
||||
|
||||
const pendingPlan = subscription.pendingPlan as PendingPaymentProviderPlan
|
||||
|
||||
const hasAiAddon = subscription.addOns?.some(
|
||||
addOn => addOn.addOnCode === AI_ADD_ON_CODE
|
||||
)
|
||||
|
||||
const pendingAiAddonCancellation =
|
||||
hasAiAddon &&
|
||||
!pendingPlan.addOns?.some(addOn => addOn.code === AI_ADD_ON_CODE)
|
||||
|
||||
const pendingAdditionalLicenses =
|
||||
(subscription.payment.pendingAdditionalLicenses &&
|
||||
subscription.payment.pendingAdditionalLicenses > 0) ||
|
||||
subscription.payment.additionalLicenses > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
{subscription.pendingPlan.name !== subscription.plan.name && (
|
||||
<Trans
|
||||
i18nKey="your_plan_is_changing_at_term_end"
|
||||
values={{
|
||||
pendingPlanName: subscription.pendingPlan.name,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pendingAdditionalLicenses && (
|
||||
<>
|
||||
{' '}
|
||||
<Trans
|
||||
i18nKey="pending_additional_licenses"
|
||||
values={{
|
||||
pendingAdditionalLicenses:
|
||||
subscription.payment.pendingAdditionalLicenses,
|
||||
pendingTotalLicenses: subscription.payment.pendingTotalLicenses,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{pendingAiAddonCancellation && (
|
||||
<>
|
||||
{' '}
|
||||
<Trans
|
||||
i18nKey="pending_addon_cancellation"
|
||||
values={{
|
||||
addOnName: ADD_ON_NAME,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Trans } from 'react-i18next'
|
||||
import { PaidSubscription } from '../../../../../../../../types/subscription/dashboard/subscription'
|
||||
|
||||
type SubscriptionRemainderProps = {
|
||||
subscription: PaidSubscription
|
||||
hideTime?: boolean
|
||||
}
|
||||
|
||||
function SubscriptionRemainder({
|
||||
subscription,
|
||||
hideTime,
|
||||
}: SubscriptionRemainderProps) {
|
||||
const stillInATrial =
|
||||
subscription.payment.trialEndsAtFormatted &&
|
||||
subscription.payment.trialEndsAt &&
|
||||
new Date(subscription.payment.trialEndsAt).getTime() > Date.now()
|
||||
|
||||
const terminationDate = hideTime
|
||||
? subscription.payment.nextPaymentDueDate
|
||||
: subscription.payment.nextPaymentDueAt
|
||||
return stillInATrial ? (
|
||||
<Trans
|
||||
i18nKey="subscription_will_remain_active_until_end_of_trial_period_x"
|
||||
values={{
|
||||
terminationDate,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="subscription_will_remain_active_until_end_of_billing_period_x"
|
||||
values={{
|
||||
terminationDate,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubscriptionRemainder
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Trans } from 'react-i18next'
|
||||
|
||||
type TrialEndingProps = {
|
||||
trialEndsAtFormatted: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TrialEnding({
|
||||
trialEndsAtFormatted,
|
||||
className,
|
||||
}: TrialEndingProps) {
|
||||
return (
|
||||
<p className={className}>
|
||||
<Trans
|
||||
i18nKey="youre_on_free_trial_which_ends_on"
|
||||
values={{ date: trialEndsAtFormatted }}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import { PaidSubscription } from '../../../../../../../types/subscription/dashboard/subscription'
|
||||
import ReactivateSubscription from '../reactivate-subscription'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
export function CanceledSubscription({
|
||||
subscription,
|
||||
}: {
|
||||
subscription: PaidSubscription
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="currently_subscribed_to_plan"
|
||||
values={{
|
||||
planName: subscription.plan.name,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="subscription_canceled_and_terminate_on_x"
|
||||
values={{
|
||||
terminateDate: subscription.payment.nextPaymentDueAt,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<strong />,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<OLButton
|
||||
href={subscription.payment.accountManagementLink}
|
||||
target="_blank"
|
||||
variant="secondary"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('view_your_invoices')}
|
||||
</OLButton>
|
||||
</p>
|
||||
<ReactivateSubscription />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PaidSubscription } from '../../../../../../../types/subscription/dashboard/subscription'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
export function ExpiredSubscription({
|
||||
subscription,
|
||||
}: {
|
||||
subscription: PaidSubscription
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>{t('your_subscription_has_expired')}</p>
|
||||
<p>
|
||||
<OLButton
|
||||
href={subscription.payment.accountManagementLink}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
variant="secondary"
|
||||
className="me-1"
|
||||
>
|
||||
{t('view_your_invoices')}
|
||||
</OLButton>
|
||||
<OLButton href="/user/subscription/plans" variant="primary">
|
||||
{t('create_new_subscription')}
|
||||
</OLButton>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user