Merge pull request #33109 from overleaf/oa-upgrade-path

[web] Upgrade path

GitOrigin-RevId: 532993e613bdc42cf92a7b10e629aa94596d854e
This commit is contained in:
Olzhas Askar
2026-04-30 16:25:14 +02:00
committed by Copybot
parent 30e0e6adaf
commit 823f11426b
12 changed files with 572 additions and 222 deletions

View File

@@ -359,6 +359,7 @@ async function successfulSubscription(req, res) {
) )
const postCheckoutRedirect = req.session?.postCheckoutRedirect const postCheckoutRedirect = req.session?.postCheckoutRedirect
const isUpgrade = req.query.upgrade === 'true'
if (!personalSubscription) { if (!personalSubscription) {
res.redirect('/user/subscription/plans') res.redirect('/user/subscription/plans')
@@ -376,6 +377,7 @@ async function successfulSubscription(req, res) {
title: 'thank_you', title: 'thank_you',
personalSubscription, personalSubscription,
postCheckoutRedirect, postCheckoutRedirect,
isUpgrade,
user: { user: {
_id: user._id, _id: user._id,
features: userInDb.features, features: userInDb.features,

View File

@@ -9,6 +9,7 @@ block entrypointVar
block append meta block append meta
meta(name='ol-subscription' data-type='json' content=personalSubscription) meta(name='ol-subscription' data-type='json' content=personalSubscription)
meta(name='ol-postCheckoutRedirect' content=postCheckoutRedirect) meta(name='ol-postCheckoutRedirect' content=postCheckoutRedirect)
meta(name='ol-isUpgrade' data-type='boolean' content=isUpgrade)
meta(name='ol-user' data-type='json' content=user) meta(name='ol-user' data-type='json' content=user)
block content block content

View File

@@ -661,6 +661,7 @@
"filter_projects": "", "filter_projects": "",
"find": "", "find": "",
"find_and_check_citations": "", "find_and_check_citations": "",
"find_out_how_to_get_the_most_out_of_your_new_subscription": "",
"find_out_more": "", "find_out_more": "",
"find_out_more_about_institution_login": "", "find_out_more_about_institution_login": "",
"find_out_more_about_the_file_outline": "", "find_out_more_about_the_file_outline": "",
@@ -727,6 +728,8 @@
"get_most_subscription_by_checking_ai_writefull": "", "get_most_subscription_by_checking_ai_writefull": "",
"get_most_subscription_by_checking_overleaf": "", "get_most_subscription_by_checking_overleaf": "",
"get_most_subscription_by_checking_overleaf_ai_writefull": "", "get_most_subscription_by_checking_overleaf_ai_writefull": "",
"get_ready_for_overleaf_at_its_best_pro": "",
"get_ready_for_overleaf_standard": "",
"get_real_time_track_changes": "", "get_real_time_track_changes": "",
"get_your_hands_on_the_ultimate_research_writing_ai_assistant": "", "get_your_hands_on_the_ultimate_research_writing_ai_assistant": "",
"git": "", "git": "",
@@ -1431,6 +1434,7 @@
"priority_support": "", "priority_support": "",
"privacy_and_terms": "", "privacy_and_terms": "",
"private": "", "private": "",
"pro": "",
"problem_talking_to_publishing_service": "", "problem_talking_to_publishing_service": "",
"problem_with_subscription_contact_us": "", "problem_with_subscription_contact_us": "",
"proceed_to_paypal": "", "proceed_to_paypal": "",
@@ -1538,6 +1542,7 @@
"refresh_page_after_linking_dropbox": "", "refresh_page_after_linking_dropbox": "",
"refresh_page_after_starting_free_trial": "", "refresh_page_after_starting_free_trial": "",
"refreshing": "", "refreshing": "",
"refund_with_description": "",
"regards": "", "regards": "",
"registering": "", "registering": "",
"reject": "", "reject": "",
@@ -1878,7 +1883,6 @@
"submit_title": "", "submit_title": "",
"subscribe": "", "subscribe": "",
"subscribe_to_find_the_symbols_you_need_faster": "", "subscribe_to_find_the_symbols_you_need_faster": "",
"subscribe_to_plan": "",
"subscription": "", "subscription": "",
"subscription_admins_cannot_be_deleted": "", "subscription_admins_cannot_be_deleted": "",
"subscription_canceled": "", "subscription_canceled": "",
@@ -2203,6 +2207,7 @@
"upgrade_to_add_more_collaborators_and_more": "", "upgrade_to_add_more_collaborators_and_more": "",
"upgrade_to_get_feature": "", "upgrade_to_get_feature": "",
"upgrade_to_get_started": "", "upgrade_to_get_started": "",
"upgrade_to_plan": "",
"upgrade_to_review": "", "upgrade_to_review": "",
"upgrade_your_subscription": "", "upgrade_your_subscription": "",
"upload": "", "upload": "",
@@ -2306,6 +2311,7 @@
"website_status": "", "website_status": "",
"wed_love_you_to_stay": "", "wed_love_you_to_stay": "",
"welcome_to_overleaf_opening_workspace": "", "welcome_to_overleaf_opening_workspace": "",
"welcome_to_plan": "",
"welcome_to_sl": "", "welcome_to_sl": "",
"well_be_here_when_youre_ready": "", "well_be_here_when_youre_ready": "",
"were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "", "were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "",
@@ -2452,6 +2458,7 @@
"youve_reached_the_fair_usage_limit_on_your_plan_you_can_start_chatting_again_in_time": "", "youve_reached_the_fair_usage_limit_on_your_plan_you_can_start_chatting_again_in_time": "",
"youve_unlinked_all_users": "", "youve_unlinked_all_users": "",
"youve_upgraded_your_plan": "", "youve_upgraded_your_plan": "",
"youve_upgraded_your_subscription": "",
"zoom_in": "", "zoom_in": "",
"zoom_out": "", "zoom_out": "",
"zoom_to": "", "zoom_to": "",

View File

@@ -12,18 +12,16 @@ function Canceled() {
<OLRow> <OLRow>
<OLCol lg={{ span: 8, offset: 2 }}> <OLCol lg={{ span: 8, offset: 2 }}>
<OLPageContentCard> <OLPageContentCard>
<div className="page-header"> <h2>{t('subscription_canceled')}</h2>
<h2>{t('subscription_canceled')}</h2>
</div>
<OLNotification <OLNotification
type="info" type="info"
content={ content={
<p> <div className="d-flex justify-content-between align-items-center gap-3">
{t('to_modify_your_subscription_go_to')}&nbsp; <span>{t('to_modify_your_subscription_go_to')}</span>
<a href="/user/subscription" rel="noopener noreferrer"> <a href="/user/subscription" rel="noopener noreferrer">
{t('manage_subscription')}. {t('manage_subscription')}
</a> </a>
</p> </div>
} }
/> />
<p> <p>

View File

@@ -1,11 +1,7 @@
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import moment from 'moment' import moment from 'moment'
import { useTranslation, Trans } from 'react-i18next' import { useTranslation, Trans } from 'react-i18next'
import { import { SubscriptionChangePreview } from '../../../../../../types/subscription/subscription-change-preview'
SubscriptionChangePreview,
AddOnPurchase,
PremiumSubscriptionChange,
} from '../../../../../../types/subscription/subscription-change-preview'
import getMeta from '@/utils/meta' import getMeta from '@/utils/meta'
import { formatCurrency } from '@/shared/utils/currency' import { formatCurrency } from '@/shared/utils/currency'
import useAsync from '@/shared/hooks/use-async' import useAsync from '@/shared/hooks/use-async'
@@ -23,6 +19,150 @@ import PaymentErrorNotification from '@/features/subscription/components/shared/
import handleStripePaymentAction from '../../util/handle-stripe-payment-action' import handleStripePaymentAction from '../../util/handle-stripe-payment-action'
import RedirectedPaymentErrorNotification from '../shared/redirected-payment-error-notification' import RedirectedPaymentErrorNotification from '../shared/redirected-payment-error-notification'
import TrialDisabledNotification from './trial-disabled-notification' import TrialDisabledNotification from './trial-disabled-notification'
import { getUpgradePlanDisplayName } from '../../util/plan-display-names'
function FuturePayments({ preview }: { preview: SubscriptionChangePreview }) {
const { t } = useTranslation()
return (
<>
<OLRow className="mt-1">
<OLCol xs={9}>{preview.nextInvoice.plan.name}</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(preview.nextInvoice.plan.amount, preview.currency)}
</OLCol>
</OLRow>
{preview.nextInvoice.addOns.map(addOn => (
<OLRow className="mt-1" key={addOn.code}>
<OLCol xs={9}>
{addOn.name}
{addOn.quantity > 1 ? ` ×${addOn.quantity}` : ''}
</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(addOn.amount, preview.currency)}
</OLCol>
</OLRow>
))}
{preview.nextInvoice.tax.rate > 0 && (
<OLRow className="mt-1">
<OLCol xs={9}>
{t('vat')} {preview.nextInvoice.tax.rate * 100}%
</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(preview.nextInvoice.tax.amount, preview.currency)}
</OLCol>
</OLRow>
)}
<OLRow className="mt-1">
<OLCol xs={9}>
{preview.nextPlan.annual ? t('total_per_year') : t('total_per_month')}
</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(preview.nextInvoice.total, preview.currency)}
</OLCol>
</OLRow>
</>
)
}
function NextPaymentDate({ preview }: { preview: SubscriptionChangePreview }) {
return (
<div className="mt-5">
<Trans
i18nKey="the_next_payment_will_be_collected_on"
values={{ date: moment(preview.nextInvoice.date).format('LL') }}
components={{ strong: <strong /> }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</div>
)
}
function PreviewLayout({ children }: { children: React.ReactNode }) {
return (
<div className="container">
<OLRow>
<OLCol md={{ offset: 2, span: 8 }}>
<RedirectedPaymentErrorNotification />
<TrialDisabledNotification />
<OLCard className="p-3">{children}</OLCard>
</OLCol>
</OLRow>
</div>
)
}
function DueTodayBlock({
preview,
filteredLineItems,
singleItemName,
headingClassName,
}: {
preview: SubscriptionChangePreview
filteredLineItems: SubscriptionChangePreview['immediateCharge']['lineItems']
singleItemName: string
headingClassName?: string
}) {
const { t } = useTranslation()
return (
<>
<h4 className={headingClassName}>{t('due_today')}:</h4>
{filteredLineItems.length > 1 ? (
filteredLineItems.map((item, index) => (
<OLRow className="mt-1" key={index}>
<OLCol xs={9}>
{item.subtotal < 0
? t('refund_with_description', {
description: item.description,
})
: item.description}
</OLCol>
<OLCol xs={3} className="text-end">
<strong>{formatCurrency(item.subtotal, preview.currency)}</strong>
</OLCol>
</OLRow>
))
) : (
<OLRow className="mt-1">
<OLCol xs={9}>{singleItemName}</OLCol>
<OLCol xs={3} className="text-end">
<strong>
{formatCurrency(
preview.immediateCharge.subtotal,
preview.currency
)}
</strong>
</OLCol>
</OLRow>
)}
{preview.immediateCharge.tax > 0 && (
<OLRow className="mt-1">
<OLCol xs={9}>
{t('vat')} {preview.nextInvoice.tax.rate * 100}%
</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(preview.immediateCharge.tax, preview.currency)}
</OLCol>
</OLRow>
)}
<OLRow className="mt-1">
<OLCol xs={9}>{t('total_today')}</OLCol>
<OLCol xs={3} className="text-end">
<strong>
{formatCurrency(preview.immediateCharge.total, preview.currency)}
</strong>
</OLCol>
</OLRow>
</>
)
}
function PreviewSubscriptionChange() { function PreviewSubscriptionChange() {
const preview = getMeta( const preview = getMeta(
@@ -33,7 +173,7 @@ function PreviewSubscriptionChange() {
const payNowTask = useAsync() const payNowTask = useAsync()
const location = useLocation() const location = useLocation()
// Filter out items that cancel each other out (AI assist items with subtotals that sum to 0) const isPremiumUpgrade = preview.change.type === 'premium-subscription'
const filteredLineItems = preview.immediateCharge.lineItems.filter( const filteredLineItems = preview.immediateCharge.lineItems.filter(
(item, index, arr) => { (item, index, arr) => {
if (!item.isAiAssist) return true if (!item.isAiAssist) return true
@@ -80,223 +220,142 @@ function PreviewSubscriptionChange() {
referrer: purchaseReferrer, referrer: purchaseReferrer,
}) })
} }
location.replace('/user/subscription/thank-you') location.replace(
isPremiumUpgrade
? '/user/subscription/thank-you?upgrade=true'
: '/user/subscription/thank-you'
)
}) })
.catch(debugConsole.error) .catch(debugConsole.error)
}, [purchaseReferrer, location, payNowTask, preview]) }, [purchaseReferrer, location, payNowTask, preview, isPremiumUpgrade])
if (preview.change.type === 'premium-subscription') {
const change = preview.change
const upgradePlanName = getUpgradePlanDisplayName(change.plan.code, t)
return (
<PreviewLayout>
<h2>{t('upgrade_to_plan', { planName: upgradePlanName })}</h2>
{payNowTask.isError && (
<PaymentErrorNotification error={payNowTask.error as FetchError} />
)}
<h3 className="mt-5">{t('payment_summary')}</h3>
<DueTodayBlock
preview={preview}
filteredLineItems={filteredLineItems}
singleItemName={change.plan.name}
headingClassName="mt-4"
/>
<hr />
<h4 className="mt-4">{t('future_payments')}:</h4>
<FuturePayments preview={preview} />
<NextPaymentDate preview={preview} />
<div className="mt-5">
<OLButton
variant="primary"
size="lg"
onClick={handlePayNowClick}
disabled={payNowTask.isLoading || payNowTask.isSuccess}
>
{t('upgrade_now')}
</OLButton>
</div>
</PreviewLayout>
)
}
const aiAddOnChange = const aiAddOnChange =
preview.change.type === 'add-on-purchase' && preview.change.type === 'add-on-purchase' &&
preview.change.addOn.code === 'assistant' preview.change.addOn.code === 'assistant'
// the driver of the change, which we can display as the immediate charge
const changeName = const changeName =
preview.change.type === 'add-on-purchase' preview.change.type === 'add-on-purchase' ? preview.change.addOn.name : ''
? (preview.change as AddOnPurchase).addOn.name
: (preview.change as PremiumSubscriptionChange).plan.name
return ( return (
<div className="container"> <PreviewLayout>
<OLRow> {preview.change.type === 'add-on-purchase' ? (
<OLCol md={{ offset: 2, span: 8 }}> <h1>
<RedirectedPaymentErrorNotification /> {t('add_add_on_to_your_plan', {
<TrialDisabledNotification /> addOnName: preview.change.addOn.name,
<OLCard className="p-3"> })}
{preview.change.type === 'add-on-purchase' ? ( </h1>
<h1> ) : null}
{t('add_add_on_to_your_plan', {
addOnName: preview.change.addOn.name,
})}
</h1>
) : preview.change.type === 'premium-subscription' ? (
<h1>
{t('subscribe_to_plan', { planName: preview.change.plan.name })}
</h1>
) : null}
{payNowTask.isError && ( {payNowTask.isError && (
<PaymentErrorNotification <PaymentErrorNotification error={payNowTask.error as FetchError} />
error={payNowTask.error as FetchError} )}
/>
)}
{aiAddOnChange && ( {aiAddOnChange && (
<div> <div>
<Trans <Trans
i18nKey="add_ai_assist_to_your_plan" i18nKey="add_ai_assist_to_your_plan"
components={{ components={{
sparkle: ( sparkle: (
<img <img
alt="sparkle" alt="sparkle"
className="ai-error-assistant-sparkle" className="ai-error-assistant-sparkle"
src={sparkleText} src={sparkleText}
aria-hidden="true" aria-hidden="true"
key="sparkle" key="sparkle"
/>
),
}}
/> />
</div> ),
)} }}
/>
</div>
)}
<OLCard className="payment-summary-card mt-5"> <OLCard className="payment-summary-card mt-5">
<h3>{t('due_today')}:</h3> <DueTodayBlock
{filteredLineItems.length > 1 ? ( preview={preview}
<> filteredLineItems={filteredLineItems}
{filteredLineItems.map((item, index) => ( singleItemName={changeName}
<OLRow key={index}> />
<OLCol xs={9}> </OLCard>
{item.subtotal < 0
? `Refund: ${item.description}`
: item.description}
</OLCol>
<OLCol xs={3} className="text-end">
<strong>
{formatCurrency(item.subtotal, preview.currency)}
</strong>
</OLCol>
</OLRow>
))}
</>
) : (
<>
<OLRow>
<OLCol xs={9}>{changeName}</OLCol>
<OLCol xs={3} className="text-end">
<strong>
{formatCurrency(
preview.immediateCharge.subtotal,
preview.currency
)}
</strong>
</OLCol>
</OLRow>
</>
)}
{preview.immediateCharge.tax > 0 && ( <div className="mt-5">
<OLRow className="mt-1"> <Trans
<OLCol xs={9}> i18nKey="this_total_reflects_the_amount_due_until"
{t('vat')} {preview.nextInvoice.tax.rate * 100}% values={{ date: moment(preview.nextInvoice.date).format('LL') }}
</OLCol> components={{ strong: <strong /> }}
<OLCol xs={3} className="text-end"> shouldUnescape
{formatCurrency( tOptions={{ interpolation: { escapeValue: true } }}
preview.immediateCharge.tax, />{' '}
preview.currency <Trans
)} i18nKey="we_will_use_your_existing_payment_method"
</OLCol> values={{ paymentMethod: preview.paymentMethod }}
</OLRow> components={{ strong: <strong /> }}
)} shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</div>
{aiAddOnChange && (
<div className="plan-terms mt-3">*{t('fair_usage_policy_applies')}</div>
)}
<OLRow className="mt-1"> <div className="mt-5">
<OLCol xs={9}>{t('total_today')}</OLCol> <OLButton
<OLCol xs={3} className="text-end"> variant="primary"
<strong> size="lg"
{formatCurrency( onClick={handlePayNowClick}
preview.immediateCharge.total, disabled={payNowTask.isLoading || payNowTask.isSuccess}
preview.currency >
)} {t('pay_now')}
</strong> </OLButton>
</OLCol> </div>
</OLRow>
</OLCard>
<div className="mt-5"> <OLCard className="payment-summary-card mt-5">
<Trans <h3>{t('future_payments')}:</h3>
i18nKey="this_total_reflects_the_amount_due_until" <FuturePayments preview={preview} />
values={{ date: moment(preview.nextInvoice.date).format('LL') }} </OLCard>
components={{ strong: <strong /> }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>{' '}
<Trans
i18nKey="we_will_use_your_existing_payment_method"
values={{ paymentMethod: preview.paymentMethod }}
components={{ strong: <strong /> }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</div>
{aiAddOnChange && (
<div className="plan-terms mt-3">
*{t('fair_usage_policy_applies')}
</div>
)}
<div className="mt-5"> <NextPaymentDate preview={preview} />
<OLButton </PreviewLayout>
variant="primary"
size="lg"
onClick={handlePayNowClick}
disabled={payNowTask.isLoading || payNowTask.isSuccess}
>
{t('pay_now')}
</OLButton>
</div>
<OLCard className="payment-summary-card mt-5">
<h3>{t('future_payments')}:</h3>
<OLRow className="mt-1">
<OLCol xs={9}>{preview.nextInvoice.plan.name}</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(
preview.nextInvoice.plan.amount,
preview.currency
)}
</OLCol>
</OLRow>
{preview.nextInvoice.addOns.map(addOn => (
<OLRow className="mt-1" key={addOn.code}>
<OLCol xs={9}>
{addOn.name}
{addOn.quantity > 1 ? ` ×${addOn.quantity}` : ''}
</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(addOn.amount, preview.currency)}
</OLCol>
</OLRow>
))}
{preview.nextInvoice.tax.rate > 0 && (
<OLRow className="mt-1">
<OLCol xs={9}>
{t('vat')} {preview.nextInvoice.tax.rate * 100}%
</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(
preview.nextInvoice.tax.amount,
preview.currency
)}
</OLCol>
</OLRow>
)}
<OLRow className="mt-1">
<OLCol xs={9}>
{preview.nextPlan.annual
? t('total_per_year')
: t('total_per_month')}
</OLCol>
<OLCol xs={3} className="text-end">
{formatCurrency(preview.nextInvoice.total, preview.currency)}
</OLCol>
</OLRow>
</OLCard>
<div className="mt-5">
<Trans
i18nKey="the_next_payment_will_be_collected_on"
values={{ date: moment(preview.nextInvoice.date).format('LL') }}
components={{ strong: <strong /> }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</div>
</OLCard>
</OLCol>
</OLRow>
</div>
) )
} }

View File

@@ -14,17 +14,28 @@ import {
} from '../../data/add-on-codes' } from '../../data/add-on-codes'
import { PaidSubscription } from '../../../../../../types/subscription/dashboard/subscription' import { PaidSubscription } from '../../../../../../types/subscription/dashboard/subscription'
import { useBroadcastUser } from '@/shared/hooks/user-channel/use-broadcast-user' import { useBroadcastUser } from '@/shared/hooks/user-channel/use-broadcast-user'
import { getUpgradePlanDisplayName } from '../../util/plan-display-names'
function SuccessfulSubscription() { function SuccessfulSubscription() {
const { t } = useTranslation() const { t } = useTranslation()
const { personalSubscription: subscription } = const { personalSubscription: subscription } =
useSubscriptionDashboardContext() useSubscriptionDashboardContext()
const postCheckoutRedirect = getMeta('ol-postCheckoutRedirect') const postCheckoutRedirect = getMeta('ol-postCheckoutRedirect')
const isUpgrade = getMeta('ol-isUpgrade')
const { appName, adminEmail } = getMeta('ol-ExposedSettings') const { appName, adminEmail } = getMeta('ol-ExposedSettings')
useBroadcastUser() useBroadcastUser()
if (!subscription || !('payment' in subscription)) return null if (!subscription || !('payment' in subscription)) return null
if (isUpgrade) {
return (
<UpgradeSuccess
subscription={subscription}
postCheckoutRedirect={postCheckoutRedirect}
/>
)
}
const onAiStandalonePlan = isStandaloneAiPlanCode(subscription.planCode) const onAiStandalonePlan = isStandaloneAiPlanCode(subscription.planCode)
return ( return (
@@ -32,9 +43,7 @@ function SuccessfulSubscription() {
<OLRow> <OLRow>
<OLCol lg={{ span: 8, offset: 2 }}> <OLCol lg={{ span: 8, offset: 2 }}>
<OLPageContentCard> <OLPageContentCard>
<div className="page-header"> <h2>{t('thanks_for_subscribing')}</h2>
<h2>{t('thanks_for_subscribing')}</h2>
</div>
<OLNotification <OLNotification
type="success" type="success"
content={ content={
@@ -57,12 +66,12 @@ function SuccessfulSubscription() {
<PriceExceptions subscription={subscription} /> <PriceExceptions subscription={subscription} />
</> </>
)} )}
<p> <div className="d-flex justify-content-between align-items-center gap-3">
{t('to_modify_your_subscription_go_to')}&nbsp; <span>{t('to_modify_your_subscription_go_to')}</span>
<a href="/user/subscription" rel="noopener noreferrer"> <a href="/user/subscription" rel="noopener noreferrer">
{t('manage_subscription')}. {t('manage_subscription')}
</a> </a>
</p> </div>
</> </>
} }
/> />
@@ -124,6 +133,84 @@ function SuccessfulSubscription() {
) )
} }
function UpgradeSuccess({
subscription,
postCheckoutRedirect,
}: {
subscription: PaidSubscription
postCheckoutRedirect: string | undefined
}) {
const { t } = useTranslation()
const planDisplayName = getUpgradePlanDisplayName(subscription.planCode, t)
let benefitsText = ''
if (subscription.planCode.startsWith('professional')) {
benefitsText = t('get_ready_for_overleaf_at_its_best_pro')
} else if (subscription.planCode.startsWith('collaborator')) {
benefitsText = t('get_ready_for_overleaf_standard')
}
return (
<div className="container">
<OLRow>
<OLCol lg={{ span: 8, offset: 2 }}>
<OLPageContentCard>
<h2>{t('welcome_to_plan', { planName: planDisplayName })}</h2>
<OLNotification
type="success"
content={
<div className="d-flex justify-content-between align-items-center gap-3">
<span>{t('youve_upgraded_your_subscription')}</span>
<a href="/user/subscription" rel="noopener noreferrer">
{t('manage_subscription')}
</a>
</div>
}
/>
<p>
<Trans
i18nKey="next_payment_of_x_collectected_on_y"
values={{
paymentAmmount: subscription.payment.displayPrice,
collectionDate: subscription.payment.nextPaymentDueAt,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[<strong />, <strong />]} // eslint-disable-line react/jsx-key
/>
</p>
<p>
<i>* {t('subject_to_additional_vat')}</i>
</p>
{benefitsText && <p>{benefitsText}</p>}
<p>
<Trans
i18nKey="find_out_how_to_get_the_most_out_of_your_new_subscription"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a
href="https://docs.overleaf.com/integrations-and-add-ons/ai-features"
target="_blank"
rel="noopener noreferrer"
/>,
]}
/>
</p>
<p>
<a
className="btn btn-primary"
href={postCheckoutRedirect || '/project'}
rel="noopener noreferrer"
>
{t('back_to_your_projects')}
</a>
</p>
</OLPageContentCard>
</OLCol>
</OLRow>
</div>
)
}
function ThankYouSection({ function ThankYouSection({
subscription, subscription,
onAiStandalonePlan, onAiStandalonePlan,

View File

@@ -0,0 +1,10 @@
import { TFunction } from 'i18next'
export function getUpgradePlanDisplayName(
planCode: string,
t: TFunction
): string {
if (planCode.startsWith('professional')) return t('pro')
if (planCode.startsWith('collaborator')) return t('standard')
return planCode
}

View File

@@ -189,6 +189,7 @@ export interface Meta {
'ol-isRegisteredViaGoogle': boolean 'ol-isRegisteredViaGoogle': boolean
'ol-isRestrictedTokenMember': boolean 'ol-isRestrictedTokenMember': boolean
'ol-isSaas': boolean 'ol-isSaas': boolean
'ol-isUpgrade': boolean
'ol-isUserGroupManager': boolean 'ol-isUserGroupManager': boolean
'ol-itm_campaign': string 'ol-itm_campaign': string
'ol-itm_content': string 'ol-itm_content': string

View File

@@ -879,6 +879,7 @@
"filters": "Filters", "filters": "Filters",
"find": "Find", "find": "Find",
"find_and_check_citations": "Find and check citations", "find_and_check_citations": "Find and check citations",
"find_out_how_to_get_the_most_out_of_your_new_subscription": "Find out how to get the most out of your new subscription by checking out <0>Overleafs AI features</0>.",
"find_out_more": "Find out More", "find_out_more": "Find out More",
"find_out_more_about_institution_login": "Find out more about institutional login", "find_out_more_about_institution_login": "Find out more about institutional login",
"find_out_more_about_the_file_outline": "Find out more about the file outline", "find_out_more_about_the_file_outline": "Find out more about the file outline",
@@ -969,6 +970,8 @@
"get_most_subscription_by_checking_overleaf": "Get the most out of your subscription by checking out <0>Overleafs features</0>.", "get_most_subscription_by_checking_overleaf": "Get the most out of your subscription by checking out <0>Overleafs features</0>.",
"get_most_subscription_by_checking_overleaf_ai_writefull": "Get the most out of your subscription by checking out <0>Overleafs features</0>, <1>Overleafs AI features</1> and <2>Writefulls features</2>.", "get_most_subscription_by_checking_overleaf_ai_writefull": "Get the most out of your subscription by checking out <0>Overleafs features</0>, <1>Overleafs AI features</1> and <2>Writefulls features</2>.",
"get_pro": "Get Pro", "get_pro": "Get Pro",
"get_ready_for_overleaf_at_its_best_pro": "Get ready for Overleaf at its best, with full access to every AI tool and unlimited collaborators per project.",
"get_ready_for_overleaf_standard": "Get ready for enhanced collaboration, with increased AI access and up to 10 collaborators per project.",
"get_real_time_track_changes": "Get real-time track changes", "get_real_time_track_changes": "Get real-time track changes",
"get_standard": "Get Standard", "get_standard": "Get Standard",
"get_student": "Get Student", "get_student": "Get Student",
@@ -2038,6 +2041,7 @@
"refresh_page_after_linking_dropbox": "Please refresh this page after linking your account to Dropbox.", "refresh_page_after_linking_dropbox": "Please refresh this page after linking your account to Dropbox.",
"refresh_page_after_starting_free_trial": "Please refresh this page after starting your free trial.", "refresh_page_after_starting_free_trial": "Please refresh this page after starting your free trial.",
"refreshing": "Refreshing", "refreshing": "Refreshing",
"refund_with_description": "Refund: __description__",
"regards": "Regards", "regards": "Regards",
"register": "Register", "register": "Register",
"register_error": "Registration error", "register_error": "Registration error",
@@ -2459,7 +2463,6 @@
"submit_title": "Submit", "submit_title": "Submit",
"subscribe": "Subscribe", "subscribe": "Subscribe",
"subscribe_to_find_the_symbols_you_need_faster": "Subscribe to find the symbols you need faster", "subscribe_to_find_the_symbols_you_need_faster": "Subscribe to find the symbols you need faster",
"subscribe_to_plan": "Subscribe to __planName__",
"subscription": "Subscription", "subscription": "Subscription",
"subscription_admins_cannot_be_deleted": "You cannot delete your account while on a subscription. Please cancel your subscription and try again. If you keep seeing this message please contact us.", "subscription_admins_cannot_be_deleted": "You cannot delete your account while on a subscription. Please cancel your subscription and try again. If you keep seeing this message please contact us.",
"subscription_canceled": "Subscription canceled", "subscription_canceled": "Subscription canceled",
@@ -2839,6 +2842,7 @@
"upgrade_to_get_feature": "Upgrade to get __feature__, plus:", "upgrade_to_get_feature": "Upgrade to get __feature__, plus:",
"upgrade_to_get_more_from_overleaf": "Upgrade to get more from Overleaf", "upgrade_to_get_more_from_overleaf": "Upgrade to get more from Overleaf",
"upgrade_to_get_started": "Upgrade to get started", "upgrade_to_get_started": "Upgrade to get started",
"upgrade_to_plan": "Upgrade to __planName__",
"upgrade_to_review": "Upgrade to review", "upgrade_to_review": "Upgrade to review",
"upgrade_your_plan": "Upgrade your plan", "upgrade_your_plan": "Upgrade your plan",
"upgrade_your_subscription": "Upgrade your subscription", "upgrade_your_subscription": "Upgrade your subscription",
@@ -2958,6 +2962,7 @@
"website_status": "Website status", "website_status": "Website status",
"wed_love_you_to_stay": "Wed love you to stay", "wed_love_you_to_stay": "Wed love you to stay",
"welcome_to_overleaf_opening_workspace": "Welcome to Overleaf. Opening your workspace.", "welcome_to_overleaf_opening_workspace": "Welcome to Overleaf. Opening your workspace.",
"welcome_to_plan": "Welcome to __planName__!",
"welcome_to_sl": "Welcome to __appName__", "welcome_to_sl": "Welcome to __appName__",
"well_be_here_when_youre_ready": "Well be here when youre ready to dive back in! 🦆", "well_be_here_when_youre_ready": "Well be here when youre ready to dive back in! 🦆",
"were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "Were making some <0>changes to project sharing</0>. This means, as someone with edit access, your name and email address will be visible to the project owner and other editors.", "were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "Were making some <0>changes to project sharing</0>. This means, as someone with edit access, your name and email address will be visible to the project owner and other editors.",
@@ -3124,6 +3129,7 @@
"youve_reached_the_fair_usage_limit_on_your_plan_you_can_start_chatting_again_in_time": "Youve reached the fair usage limit on your plan. You can start chatting again in __time__.", "youve_reached_the_fair_usage_limit_on_your_plan_you_can_start_chatting_again_in_time": "Youve reached the fair usage limit on your plan. You can start chatting again in __time__.",
"youve_unlinked_all_users": "Youve unlinked all users", "youve_unlinked_all_users": "Youve unlinked all users",
"youve_upgraded_your_plan": "Youve upgraded your plan!", "youve_upgraded_your_plan": "Youve upgraded your plan!",
"youve_upgraded_your_subscription": "Youve upgraded your subscription",
"zh-CN": "Chinese", "zh-CN": "Chinese",
"zip_contents_too_large": "Zip contents too large", "zip_contents_too_large": "Zip contents too large",
"zoom_in": "Zoom in", "zoom_in": "Zoom in",

View File

@@ -0,0 +1,99 @@
import sinon from 'sinon'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import { cloneDeep } from 'lodash'
import PreviewSubscriptionChange from '../../../../../../frontend/js/features/subscription/components/preview-subscription-change/root'
import { SubscriptionChangePreview } from '../../../../../../types/subscription/subscription-change-preview'
import { location } from '@/shared/components/location'
const premiumPreview: SubscriptionChangePreview = {
change: {
type: 'premium-subscription',
plan: { code: 'professional-annual', name: 'Overleaf Professional' },
},
currency: 'USD',
paymentMethod: 'Visa **** 1111',
netTerms: 0,
nextPlan: { annual: true },
immediateCharge: {
subtotal: 100,
tax: 20,
total: 120,
discount: 0,
lineItems: [],
},
nextInvoice: {
date: '2026-05-01T00:00:00.000Z',
plan: { name: 'Overleaf Professional', amount: 100 },
addOns: [],
subtotal: 100,
tax: { rate: 0.2, amount: 20 },
total: 120,
},
}
describe('<PreviewSubscriptionChange/> upgrade variant', function () {
beforeEach(function () {
this.locationWrapperSandbox = sinon.createSandbox()
this.locationWrapperStub = this.locationWrapperSandbox.stub(location)
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
this.locationWrapperSandbox.restore()
})
it('renders the "Upgrade to Pro" heading for a professional plan', function () {
window.metaAttributesCache.set(
'ol-subscriptionChangePreview',
premiumPreview
)
render(<PreviewSubscriptionChange />)
screen.getByRole('heading', { name: /upgrade to pro/i })
})
it('renders the "Upgrade to Standard" heading for a collaborator plan', function () {
const preview = cloneDeep(premiumPreview)
preview.change = {
type: 'premium-subscription',
plan: { code: 'collaborator-annual', name: 'Overleaf Standard' },
}
window.metaAttributesCache.set('ol-subscriptionChangePreview', preview)
render(<PreviewSubscriptionChange />)
screen.getByRole('heading', { name: /upgrade to standard/i })
})
it('renders the payment summary, due today and future payments sections', function () {
window.metaAttributesCache.set(
'ol-subscriptionChangePreview',
premiumPreview
)
render(<PreviewSubscriptionChange />)
screen.getByRole('heading', { name: /payment summary/i })
screen.getByRole('heading', { name: /due today/i })
screen.getByText(/total today/i)
screen.getByRole('heading', { name: /future payments/i })
})
it('redirects to the thank-you page with upgrade=true after a successful payment', async function () {
fetchMock.post('/user/subscription/update', 200)
window.metaAttributesCache.set(
'ol-subscriptionChangePreview',
premiumPreview
)
render(<PreviewSubscriptionChange />)
fireEvent.click(screen.getByRole('button', { name: /upgrade now/i }))
await waitFor(() => {
sinon.assert.calledOnce(this.locationWrapperStub.replace)
})
sinon.assert.calledWithMatch(
this.locationWrapperStub.replace,
'/user/subscription/thank-you?upgrade=true'
)
})
})

View File

@@ -2,7 +2,10 @@ import { expect } from 'chai'
import { screen, within } from '@testing-library/react' import { screen, within } from '@testing-library/react'
import SuccessfulSubscription from '../../../../../../frontend/js/features/subscription/components/successful-subscription/successful-subscription' import SuccessfulSubscription from '../../../../../../frontend/js/features/subscription/components/successful-subscription/successful-subscription'
import { renderWithSubscriptionDashContext } from '../../helpers/render-with-subscription-dash-context' import { renderWithSubscriptionDashContext } from '../../helpers/render-with-subscription-dash-context'
import { annualActiveSubscription } from '../../fixtures/subscriptions' import {
annualActiveSubscription,
annualActiveSubscriptionPro,
} from '../../fixtures/subscriptions'
import { ExposedSettings } from '../../../../../../types/exposed-settings' import { ExposedSettings } from '../../../../../../types/exposed-settings'
import { UserProvider } from '@/shared/context/user-context' import { UserProvider } from '@/shared/context/user-context'
@@ -84,4 +87,80 @@ describe('successful subscription page', function () {
}) })
expect(backToYourProjectsLink.getAttribute('href')).to.equal('/project') expect(backToYourProjectsLink.getAttribute('href')).to.equal('/project')
}) })
describe('upgrade variant', function () {
it('renders the upgrade success page when isUpgrade is true', function () {
renderWithSubscriptionDashContext(
<UserProvider>
<SuccessfulSubscription />
</UserProvider>,
{
metaTags: [
{
name: 'ol-ExposedSettings',
value: {
adminEmail: 'foo@example.com',
} as ExposedSettings,
},
{ name: 'ol-subscription', value: annualActiveSubscriptionPro },
{ name: 'ol-isUpgrade', value: true },
],
}
)
screen.getByRole('heading', { name: /welcome to pro/i })
const alert = screen.getByRole('alert')
within(alert).getByText(/you.ve upgraded your subscription/i)
const manageLink = within(alert).getByRole('link', {
name: /manage subscription/i,
})
expect(manageLink.getAttribute('href')).to.equal('/user/subscription')
expect(
screen
.getByText(/the next payment of/i)
.textContent?.replace(/\xA0/g, ' ')
).to.equal(
`The next payment of ${annualActiveSubscriptionPro.payment.displayPrice} will be collected on ${annualActiveSubscriptionPro.payment.nextPaymentDueAt}.`
)
screen.getByText(/prices may be subject to additional vat/i)
screen.getByText(/full access to every AI tool/i, { exact: false })
const aiFeaturesLink = screen.getByRole('link', {
name: /Overleaf.s AI features/i,
})
expect(aiFeaturesLink.getAttribute('href')).to.equal(
'https://docs.overleaf.com/integrations-and-add-ons/ai-features'
)
expect(aiFeaturesLink.getAttribute('target')).to.equal('_blank')
expect(aiFeaturesLink.getAttribute('rel')).to.equal('noopener noreferrer')
const backLink = screen.getByRole('link', {
name: /back to your projects/i,
})
expect(backLink.getAttribute('href')).to.equal('/project')
})
it('renders the standard success page when isUpgrade is not set', function () {
renderWithSubscriptionDashContext(
<UserProvider>
<SuccessfulSubscription />
</UserProvider>,
{
metaTags: [
{
name: 'ol-ExposedSettings',
value: {
adminEmail: 'foo@example.com',
} as ExposedSettings,
},
{ name: 'ol-subscription', value: annualActiveSubscription },
],
}
)
screen.getByRole('heading', { name: /thanks for subscribing/i })
})
})
}) })

View File

@@ -405,6 +405,7 @@ describe('SubscriptionController', function () {
title: 'thank_you', title: 'thank_you',
personalSubscription: 'foo', personalSubscription: 'foo',
postCheckoutRedirect: undefined, postCheckoutRedirect: undefined,
isUpgrade: false,
user: { user: {
_id: ctx.user._id, _id: ctx.user._id,
features: ctx.user.features, features: ctx.user.features,