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,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>
</>
)
}