Przeglądaj źródła

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

[web] Upgrade path

GitOrigin-RevId: 532993e613bdc42cf92a7b10e629aa94596d854e
Olzhas Askar 3 miesięcy temu
rodzic
commit
823f11426b

+ 2 - 0
services/web/app/src/Features/Subscription/SubscriptionController.mjs

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

+ 1 - 0
services/web/app/views/subscriptions/successful-subscription-react.pug

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

+ 8 - 1
services/web/frontend/extracted-translations.json

@@ -661,6 +661,7 @@
   "filter_projects": "",
   "find": "",
   "find_and_check_citations": "",
+  "find_out_how_to_get_the_most_out_of_your_new_subscription": "",
   "find_out_more": "",
   "find_out_more_about_institution_login": "",
   "find_out_more_about_the_file_outline": "",
@@ -727,6 +728,8 @@
   "get_most_subscription_by_checking_ai_writefull": "",
   "get_most_subscription_by_checking_overleaf": "",
   "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_your_hands_on_the_ultimate_research_writing_ai_assistant": "",
   "git": "",
@@ -1431,6 +1434,7 @@
   "priority_support": "",
   "privacy_and_terms": "",
   "private": "",
+  "pro": "",
   "problem_talking_to_publishing_service": "",
   "problem_with_subscription_contact_us": "",
   "proceed_to_paypal": "",
@@ -1538,6 +1542,7 @@
   "refresh_page_after_linking_dropbox": "",
   "refresh_page_after_starting_free_trial": "",
   "refreshing": "",
+  "refund_with_description": "",
   "regards": "",
   "registering": "",
   "reject": "",
@@ -1878,7 +1883,6 @@
   "submit_title": "",
   "subscribe": "",
   "subscribe_to_find_the_symbols_you_need_faster": "",
-  "subscribe_to_plan": "",
   "subscription": "",
   "subscription_admins_cannot_be_deleted": "",
   "subscription_canceled": "",
@@ -2203,6 +2207,7 @@
   "upgrade_to_add_more_collaborators_and_more": "",
   "upgrade_to_get_feature": "",
   "upgrade_to_get_started": "",
+  "upgrade_to_plan": "",
   "upgrade_to_review": "",
   "upgrade_your_subscription": "",
   "upload": "",
@@ -2306,6 +2311,7 @@
   "website_status": "",
   "wed_love_you_to_stay": "",
   "welcome_to_overleaf_opening_workspace": "",
+  "welcome_to_plan": "",
   "welcome_to_sl": "",
   "well_be_here_when_youre_ready": "",
   "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_unlinked_all_users": "",
   "youve_upgraded_your_plan": "",
+  "youve_upgraded_your_subscription": "",
   "zoom_in": "",
   "zoom_out": "",
   "zoom_to": "",

+ 5 - 7
services/web/frontend/js/features/subscription/components/canceled-subscription/canceled.tsx

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

+ 268 - 209
services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx

@@ -1,11 +1,7 @@
 import { useCallback, useEffect } from 'react'
 import moment from 'moment'
 import { useTranslation, Trans } from 'react-i18next'
-import {
-  SubscriptionChangePreview,
-  AddOnPurchase,
-  PremiumSubscriptionChange,
-} from '../../../../../../types/subscription/subscription-change-preview'
+import { SubscriptionChangePreview } from '../../../../../../types/subscription/subscription-change-preview'
 import getMeta from '@/utils/meta'
 import { formatCurrency } from '@/shared/utils/currency'
 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 RedirectedPaymentErrorNotification from '../shared/redirected-payment-error-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() {
   const preview = getMeta(
@@ -33,7 +173,7 @@ function PreviewSubscriptionChange() {
   const payNowTask = useAsync()
   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(
     (item, index, arr) => {
       if (!item.isAiAssist) return true
@@ -80,223 +220,142 @@ function PreviewSubscriptionChange() {
             referrer: purchaseReferrer,
           })
         }
-        location.replace('/user/subscription/thank-you')
+        location.replace(
+          isPremiumUpgrade
+            ? '/user/subscription/thank-you?upgrade=true'
+            : '/user/subscription/thank-you'
+        )
       })
       .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 =
     preview.change.type === 'add-on-purchase' &&
     preview.change.addOn.code === 'assistant'
 
-  // the driver of the change, which we can display as the immediate charge
   const changeName =
-    preview.change.type === 'add-on-purchase'
-      ? (preview.change as AddOnPurchase).addOn.name
-      : (preview.change as PremiumSubscriptionChange).plan.name
+    preview.change.type === 'add-on-purchase' ? preview.change.addOn.name : ''
 
   return (
-    <div className="container">
-      <OLRow>
-        <OLCol md={{ offset: 2, span: 8 }}>
-          <RedirectedPaymentErrorNotification />
-          <TrialDisabledNotification />
-          <OLCard className="p-3">
-            {preview.change.type === 'add-on-purchase' ? (
-              <h1>
-                {t('add_add_on_to_your_plan', {
-                  addOnName: preview.change.addOn.name,
-                })}
-              </h1>
-            ) : preview.change.type === 'premium-subscription' ? (
-              <h1>
-                {t('subscribe_to_plan', { planName: preview.change.plan.name })}
-              </h1>
-            ) : null}
-
-            {payNowTask.isError && (
-              <PaymentErrorNotification
-                error={payNowTask.error as FetchError}
-              />
-            )}
-
-            {aiAddOnChange && (
-              <div>
-                <Trans
-                  i18nKey="add_ai_assist_to_your_plan"
-                  components={{
-                    sparkle: (
-                      <img
-                        alt="sparkle"
-                        className="ai-error-assistant-sparkle"
-                        src={sparkleText}
-                        aria-hidden="true"
-                        key="sparkle"
-                      />
-                    ),
-                  }}
+    <PreviewLayout>
+      {preview.change.type === 'add-on-purchase' ? (
+        <h1>
+          {t('add_add_on_to_your_plan', {
+            addOnName: preview.change.addOn.name,
+          })}
+        </h1>
+      ) : null}
+
+      {payNowTask.isError && (
+        <PaymentErrorNotification error={payNowTask.error as FetchError} />
+      )}
+
+      {aiAddOnChange && (
+        <div>
+          <Trans
+            i18nKey="add_ai_assist_to_your_plan"
+            components={{
+              sparkle: (
+                <img
+                  alt="sparkle"
+                  className="ai-error-assistant-sparkle"
+                  src={sparkleText}
+                  aria-hidden="true"
+                  key="sparkle"
                 />
-              </div>
-            )}
-
-            <OLCard className="payment-summary-card mt-5">
-              <h3>{t('due_today')}:</h3>
-              {filteredLineItems.length > 1 ? (
-                <>
-                  {filteredLineItems.map((item, index) => (
-                    <OLRow key={index}>
-                      <OLCol xs={9}>
-                        {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>
-                </>
-              )}
+              ),
+            }}
+          />
+        </div>
+      )}
 
-              {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>
-              )}
+      <OLCard className="payment-summary-card mt-5">
+        <DueTodayBlock
+          preview={preview}
+          filteredLineItems={filteredLineItems}
+          singleItemName={changeName}
+        />
+      </OLCard>
 
-              <OLRow className="mt-1">
-                <OLCol xs={9}>{t('total_today')}</OLCol>
-                <OLCol xs={3} className="text-end">
-                  <strong>
-                    {formatCurrency(
-                      preview.immediateCharge.total,
-                      preview.currency
-                    )}
-                  </strong>
-                </OLCol>
-              </OLRow>
-            </OLCard>
-
-            <div className="mt-5">
-              <Trans
-                i18nKey="this_total_reflects_the_amount_due_until"
-                values={{ date: moment(preview.nextInvoice.date).format('LL') }}
-                components={{ strong: <strong /> }}
-                shouldUnescape
-                tOptions={{ interpolation: { escapeValue: true } }}
-              />{' '}
-              <Trans
-                i18nKey="we_will_use_your_existing_payment_method"
-                values={{ paymentMethod: preview.paymentMethod }}
-                components={{ strong: <strong /> }}
-                shouldUnescape
-                tOptions={{ interpolation: { escapeValue: true } }}
-              />
-            </div>
-            {aiAddOnChange && (
-              <div className="plan-terms mt-3">
-                *{t('fair_usage_policy_applies')}
-              </div>
-            )}
-
-            <div className="mt-5">
-              <OLButton
-                variant="primary"
-                size="lg"
-                onClick={handlePayNowClick}
-                disabled={payNowTask.isLoading || payNowTask.isSuccess}
-              >
-                {t('pay_now')}
-              </OLButton>
-            </div>
-
-            <OLCard className="payment-summary-card mt-5">
-              <h3>{t('future_payments')}:</h3>
-              <OLRow className="mt-1">
-                <OLCol xs={9}>{preview.nextInvoice.plan.name}</OLCol>
-                <OLCol xs={3} className="text-end">
-                  {formatCurrency(
-                    preview.nextInvoice.plan.amount,
-                    preview.currency
-                  )}
-                </OLCol>
-              </OLRow>
-
-              {preview.nextInvoice.addOns.map(addOn => (
-                <OLRow className="mt-1" key={addOn.code}>
-                  <OLCol xs={9}>
-                    {addOn.name}
-                    {addOn.quantity > 1 ? ` ×${addOn.quantity}` : ''}
-                  </OLCol>
-                  <OLCol xs={3} className="text-end">
-                    {formatCurrency(addOn.amount, preview.currency)}
-                  </OLCol>
-                </OLRow>
-              ))}
-
-              {preview.nextInvoice.tax.rate > 0 && (
-                <OLRow className="mt-1">
-                  <OLCol xs={9}>
-                    {t('vat')} {preview.nextInvoice.tax.rate * 100}%
-                  </OLCol>
-                  <OLCol xs={3} className="text-end">
-                    {formatCurrency(
-                      preview.nextInvoice.tax.amount,
-                      preview.currency
-                    )}
-                  </OLCol>
-                </OLRow>
-              )}
+      <div className="mt-5">
+        <Trans
+          i18nKey="this_total_reflects_the_amount_due_until"
+          values={{ date: moment(preview.nextInvoice.date).format('LL') }}
+          components={{ strong: <strong /> }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
+        />{' '}
+        <Trans
+          i18nKey="we_will_use_your_existing_payment_method"
+          values={{ paymentMethod: preview.paymentMethod }}
+          components={{ strong: <strong /> }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
+        />
+      </div>
+      {aiAddOnChange && (
+        <div className="plan-terms mt-3">*{t('fair_usage_policy_applies')}</div>
+      )}
 
-              <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>
+      <div className="mt-5">
+        <OLButton
+          variant="primary"
+          size="lg"
+          onClick={handlePayNowClick}
+          disabled={payNowTask.isLoading || payNowTask.isSuccess}
+        >
+          {t('pay_now')}
+        </OLButton>
+      </div>
+
+      <OLCard className="payment-summary-card mt-5">
+        <h3>{t('future_payments')}:</h3>
+        <FuturePayments preview={preview} />
+      </OLCard>
+
+      <NextPaymentDate preview={preview} />
+    </PreviewLayout>
   )
 }
 

+ 94 - 7
services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx

@@ -14,17 +14,28 @@ import {
 } from '../../data/add-on-codes'
 import { PaidSubscription } from '../../../../../../types/subscription/dashboard/subscription'
 import { useBroadcastUser } from '@/shared/hooks/user-channel/use-broadcast-user'
+import { getUpgradePlanDisplayName } from '../../util/plan-display-names'
 
 function SuccessfulSubscription() {
   const { t } = useTranslation()
   const { personalSubscription: subscription } =
     useSubscriptionDashboardContext()
   const postCheckoutRedirect = getMeta('ol-postCheckoutRedirect')
+  const isUpgrade = getMeta('ol-isUpgrade')
   const { appName, adminEmail } = getMeta('ol-ExposedSettings')
   useBroadcastUser()
 
   if (!subscription || !('payment' in subscription)) return null
 
+  if (isUpgrade) {
+    return (
+      <UpgradeSuccess
+        subscription={subscription}
+        postCheckoutRedirect={postCheckoutRedirect}
+      />
+    )
+  }
+
   const onAiStandalonePlan = isStandaloneAiPlanCode(subscription.planCode)
 
   return (
@@ -32,9 +43,7 @@ function SuccessfulSubscription() {
       <OLRow>
         <OLCol lg={{ span: 8, offset: 2 }}>
           <OLPageContentCard>
-            <div className="page-header">
-              <h2>{t('thanks_for_subscribing')}</h2>
-            </div>
+            <h2>{t('thanks_for_subscribing')}</h2>
             <OLNotification
               type="success"
               content={
@@ -57,12 +66,12 @@ function SuccessfulSubscription() {
                       <PriceExceptions subscription={subscription} />
                     </>
                   )}
-                  <p>
-                    {t('to_modify_your_subscription_go_to')}&nbsp;
+                  <div className="d-flex justify-content-between align-items-center gap-3">
+                    <span>{t('to_modify_your_subscription_go_to')}</span>
                     <a href="/user/subscription" rel="noopener noreferrer">
-                      {t('manage_subscription')}.
+                      {t('manage_subscription')}
                     </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({
   subscription,
   onAiStandalonePlan,

+ 10 - 0
services/web/frontend/js/features/subscription/util/plan-display-names.ts

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

+ 1 - 0
services/web/frontend/js/utils/meta.ts

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

+ 7 - 1
services/web/locales/en.json

@@ -879,6 +879,7 @@
   "filters": "Filters",
   "find": "Find",
   "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>Overleaf’s AI features</0>.",
   "find_out_more": "Find out More",
   "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",
@@ -969,6 +970,8 @@
   "get_most_subscription_by_checking_overleaf": "Get the most out of your subscription by checking out <0>Overleaf’s features</0>.",
   "get_most_subscription_by_checking_overleaf_ai_writefull": "Get the most out of your subscription by checking out <0>Overleaf’s features</0>, <1>Overleaf’s AI features</1> and <2>Writefull’s features</2>.",
   "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_standard": "Get Standard",
   "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_starting_free_trial": "Please refresh this page after starting your free trial.",
   "refreshing": "Refreshing",
+  "refund_with_description": "Refund: __description__",
   "regards": "Regards",
   "register": "Register",
   "register_error": "Registration error",
@@ -2459,7 +2463,6 @@
   "submit_title": "Submit",
   "subscribe": "Subscribe",
   "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_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",
@@ -2839,6 +2842,7 @@
   "upgrade_to_get_feature": "Upgrade to get __feature__, plus:",
   "upgrade_to_get_more_from_overleaf": "Upgrade to get more from Overleaf",
   "upgrade_to_get_started": "Upgrade to get started",
+  "upgrade_to_plan": "Upgrade to __planName__",
   "upgrade_to_review": "Upgrade to review",
   "upgrade_your_plan": "Upgrade your plan",
   "upgrade_your_subscription": "Upgrade your subscription",
@@ -2958,6 +2962,7 @@
   "website_status": "Website status",
   "wed_love_you_to_stay": "We’d love you to stay",
   "welcome_to_overleaf_opening_workspace": "Welcome to Overleaf. Opening your workspace.",
+  "welcome_to_plan": "Welcome to __planName__!",
   "welcome_to_sl": "Welcome to __appName__",
   "well_be_here_when_youre_ready": "We’ll be here when you’re ready to dive back in! 🦆",
   "were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "We’re 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": "You’ve reached the fair usage limit on your plan. You can start chatting again in __time__.",
   "youve_unlinked_all_users": "You’ve unlinked all users",
   "youve_upgraded_your_plan": "You’ve upgraded your plan!",
+  "youve_upgraded_your_subscription": "You’ve upgraded your subscription",
   "zh-CN": "Chinese",
   "zip_contents_too_large": "Zip contents too large",
   "zoom_in": "Zoom in",

+ 99 - 0
services/web/test/frontend/features/subscription/components/preview-subscription-change/preview-subscription-change.test.tsx

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

+ 80 - 1
services/web/test/frontend/features/subscription/components/successful-subscription/successful-subscription.test.tsx

@@ -2,7 +2,10 @@ import { expect } from 'chai'
 import { screen, within } from '@testing-library/react'
 import SuccessfulSubscription from '../../../../../../frontend/js/features/subscription/components/successful-subscription/successful-subscription'
 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 { UserProvider } from '@/shared/context/user-context'
 
@@ -84,4 +87,80 @@ describe('successful subscription page', function () {
     })
     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 })
+    })
+  })
 })

+ 1 - 0
services/web/test/unit/src/Subscription/SubscriptionController.test.mjs

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