فهرست منبع

Merge pull request #29394 from overleaf/rh-compile-timeout-modal

Add compile timeout modal for compile-timeout-target-plans test

GitOrigin-RevId: b352cb239742aa7ffbef7f3cd5c65ac719569ebf
roo hutton 9 ماه پیش
والد
کامیت
2e11f2c7b7

+ 47 - 52
services/web/app/src/Features/Project/ProjectController.mjs

@@ -395,7 +395,6 @@ const _ProjectController = {
       'track-pdf-download',
       !anonymous && 'writefull-oauth-promotion',
       'hotjar',
-      'hotjar-editor-onboarding',
       'editor-redesign',
       'overleaf-assist-bundle',
       'word-count-client',
@@ -404,6 +403,7 @@ const _ProjectController = {
       'writefull-frontend-migration',
       'chat-edit-delete',
       'compile-timeout-remove-info',
+      'compile-timeout-target-plans',
     ].filter(Boolean)
 
     const getUserValues = async userId =>
@@ -507,38 +507,11 @@ const _ProjectController = {
       }
 
       const getSplitTestAssignment = async splitTest => {
-        if (splitTest === 'hotjar-editor-onboarding') {
-          const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
-          const userRegisteredMoreThan7DaysAgo =
-            user.signUpDate && user.signUpDate < sevenDaysAgo
-
-          const isExcluded =
-            user.betaProgram ||
-            inEnterpriseCommons ||
-            userIsMemberOfGroupSubscription ||
-            userRegisteredMoreThan7DaysAgo
-
-          if (!isExcluded) {
-            return await SplitTestHandler.promises.getAssignment(
-              req,
-              res,
-              splitTest
-            )
-          } else {
-            return {
-              variant: 'default',
-              analytics: {
-                segmentation: {},
-              },
-            }
-          }
-        } else {
-          return await SplitTestHandler.promises.getAssignment(
-            req,
-            res,
-            splitTest
-          )
-        }
+        return await SplitTestHandler.promises.getAssignment(
+          req,
+          res,
+          splitTest
+        )
       }
       const splitTestAssignments = {}
       await Promise.all(
@@ -811,6 +784,19 @@ const _ProjectController = {
         isOverleafAssistBundleEnabled &&
         (await ProjectController._getAddonPrices(req, res))
 
+      let standardPlanPricing
+      let recommendedCurrency
+      if (Features.hasFeature('saas')) {
+        standardPlanPricing = await ProjectController._getPlanPricing(
+          req,
+          res,
+          'collaborator'
+        )
+        const { currency } =
+          await SubscriptionController.getRecommendedCurrency(req, res)
+        recommendedCurrency = currency
+      }
+
       let planCode = subscription?.planCode
       if (!planCode && !userInNonIndividualSub) {
         planCode = 'personal'
@@ -818,6 +804,12 @@ const _ProjectController = {
 
       const planDetails = Settings.plans.find(p => p.planCode === planCode)
 
+      const shouldLoadHotjar =
+        splitTestAssignments['compile-timeout-target-plans']?.variant ===
+          'enabled' &&
+        !userHasPremiumSub &&
+        !userInNonIndividualSub
+
       res.render(template, {
         title: project.name,
         priority_title: true,
@@ -897,15 +889,15 @@ const _ProjectController = {
         otMigrationStage: project.overleaf?.history?.otMigrationStage ?? 0,
         projectTags,
         isSaas: Features.hasFeature('saas'),
-        shouldLoadHotjar:
-          splitTestAssignments['hotjar-editor-onboarding']?.variant ===
-          'enabled',
+        shouldLoadHotjar,
         isOverleafAssistBundleEnabled,
         customerIoEnabled,
         addonPrices,
         compileSettings: {
           compileTimeout: ownerFeatures?.compileTimeout,
         },
+        standardPlanPricing,
+        recommendedCurrency,
       })
       timer.done()
     } catch (err) {
@@ -914,30 +906,33 @@ const _ProjectController = {
     }
   },
 
-  async _getPaywallPlansPrices(
-    req,
-    res,
-    paywallPlans = ['collaborator', 'student']
-  ) {
-    const plansData = {}
-
+  async _getPlanPricing(req, res, plan = 'collaborator') {
     const locale = req.i18n.language
     const { currency } = await SubscriptionController.getRecommendedCurrency(
       req,
       res
     )
 
-    paywallPlans.forEach(plan => {
-      const planPrice = Settings.localizedPlanPricing[currency][plan].monthly
-      const formattedPlanPrice = formatCurrency(
-        planPrice,
+    const pricingForCurrency = Settings.localizedPlanPricing[currency]
+    if (!pricingForCurrency) {
+      return null
+    }
+
+    const planPricing = pricingForCurrency[plan]
+    if (!planPricing) {
+      return null
+    }
+
+    return {
+      monthly: formatCurrency(planPricing.monthly, currency, locale, true),
+      annual: formatCurrency(planPricing.annual, currency, locale, true),
+      monthlyTimesTwelve: formatCurrency(
+        planPricing.monthlyTimesTwelve,
         currency,
         locale,
         true
-      )
-      plansData[plan] = formattedPlanPrice
-    })
-    return plansData
+      ),
+    }
   },
 
   async _getAddonPrices(req, res, addonPlans = ['assistant']) {
@@ -1319,7 +1314,7 @@ const ProjectController = {
   _injectProjectUsers: _ProjectController._injectProjectUsers,
   _isInPercentageRollout: _ProjectController._isInPercentageRollout,
   _refreshFeatures: _ProjectController._refreshFeatures,
-  _getPaywallPlansPrices: _ProjectController._getPaywallPlansPrices,
+  _getPlanPricing: _ProjectController._getPlanPricing,
   _getAddonPrices: _ProjectController._getAddonPrices,
   _setWritefullTrialState: _ProjectController._setWritefullTrialState,
 }

+ 7 - 0
services/web/app/views/project/editor/_meta.pug

@@ -45,6 +45,13 @@ meta(name='ol-compileSettings' data-type="json" content=compileSettings)
 if(isOverleafAssistBundleEnabled)
 	//- expose plans info to show prices in paywall-change-compile-timeout test 
 	meta(name="ol-addonPrices" data-type="json" content=addonPrices)
+if (standardPlanPricing)
+	meta(
+		name='ol-recommendedCurrency'
+		data-type='string'
+		content=recommendedCurrency
+	)
+	meta(name="ol-standardPlanPricing" data-type="json" content=standardPlanPricing)
 // translations for the loading page, before i18n has loaded in the client
 meta(name="ol-loadingText" data-type="string" content=translate("loading"))
 meta(name="ol-translationIoNotLoaded" data-type="string" content=translate("could_not_connect_to_websocket_server"))

+ 11 - 0
services/web/frontend/extracted-translations.json

@@ -137,6 +137,7 @@
   "an_email_has_already_been_sent_to": "",
   "an_error_occured_while_restoring_project": "",
   "an_error_occurred_when_verifying_the_coupon_code": "",
+  "and_much_more": "",
   "annual_discount": "",
   "anonymous": "",
   "anyone_with_link_can_edit": "",
@@ -189,6 +190,7 @@
   "billed_monthly_at": "",
   "billed_yearly": "",
   "billing": "",
+  "billing_period_sentence_case": "",
   "binary_history_error": "",
   "blank_project": "",
   "blocked_filename": "",
@@ -200,6 +202,7 @@
   "bullet_list": "",
   "buy_licenses": "",
   "buy_more_licenses": "",
+  "buy_now_no_exclamation_mark": "",
   "by_subscribing_you_agree_to_our_terms_of_service": "",
   "can_link_institution_email_acct_to_institution_acct": "",
   "can_link_your_institution_acct_2": "",
@@ -307,6 +310,7 @@
   "compile_larger_projects": "",
   "compile_mode": "",
   "compile_terminated_by_user": "",
+  "compile_timeout_modal_intro": "",
   "compiler": "",
   "compiling": "",
   "compliance": "",
@@ -666,6 +670,7 @@
   "get_error_assist": "",
   "get_exclusive_access_to_labs": "",
   "get_in_touch": "",
+  "get_more_compile_time": "",
   "get_most_subscription_by_checking_ai_writefull": "",
   "get_most_subscription_by_checking_overleaf": "",
   "get_most_subscription_by_checking_overleaf_ai_writefull": "",
@@ -694,6 +699,7 @@
   "github_file_name_error": "",
   "github_file_sync_error": "",
   "github_git_folder_error": "",
+  "github_integration": "",
   "github_integration_lowercase": "",
   "github_is_no_longer_connected": "",
   "github_is_premium": "",
@@ -1072,6 +1078,7 @@
   "money_back_guarantee": "",
   "month": "",
   "month_plural": "",
+  "monthly": "",
   "more": "",
   "more_actions": "",
   "more_collabs_per_project": "",
@@ -1265,6 +1272,7 @@
   "per_license": "",
   "per_month": "",
   "per_month_x_annually": "",
+  "per_year": "",
   "percent_is_the_percentage_of_the_line_width": "",
   "permanently_disables_the_preview": "",
   "personal_library": "",
@@ -1514,6 +1522,7 @@
   "saml_missing_signature_error": "",
   "saml_response": "",
   "save": "",
+  "save_20_percent": "",
   "save_or_cancel-cancel": "",
   "save_or_cancel-or": "",
   "save_or_cancel-save": "",
@@ -2093,6 +2102,7 @@
   "verify_your_email_address": "",
   "view": "",
   "view_all": "",
+  "view_all_plans": "",
   "view_audit_logs_group_subtext": "",
   "view_billing_details": "",
   "view_code": "",
@@ -2173,6 +2183,7 @@
   "x_price_per_user": "",
   "x_price_per_year": "",
   "year": "",
+  "yearly": "",
   "yes_move_me_to_personal_plan": "",
   "you": "",
   "you_already_have_a_subscription": "",

+ 240 - 0
services/web/frontend/js/features/pdf-preview/components/compile-timeout-paywall-modal.tsx

@@ -0,0 +1,240 @@
+import '../../../../stylesheets/components/compile-time-paywall-modal.scss'
+
+import {
+  OLModal,
+  OLModalBody,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/shared/components/ol/ol-modal'
+import OLButton from '@/shared/components/ol/ol-button'
+import MaterialIcon from '@/shared/components/material-icon'
+import BillingPeriodToggle, {
+  type BillingPeriod,
+} from '@/shared/components/billing-period-toggle'
+import getMeta from '@/utils/meta'
+import { sendMB } from '@/infrastructure/event-tracking'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+const PLAN_CODE = {
+  monthly: 'collaborator',
+  annual: 'collaborator-annual',
+} as const
+
+export type CompileTimeoutPaywallModalProps = {
+  show: boolean
+  onHide: () => void
+}
+
+export function CompileTimeoutPaywallModal({
+  show,
+  onHide,
+}: CompileTimeoutPaywallModalProps) {
+  const { t } = useTranslation()
+  const standardPlanPricing = getMeta('ol-standardPlanPricing')
+  const currency = getMeta('ol-recommendedCurrency')
+
+  const supportsAnnualPricing = Boolean(standardPlanPricing?.annual)
+  const [annual, setAnnual] = useState(false)
+
+  useEffect(() => {
+    if (!show) {
+      setAnnual(false)
+    }
+  }, [show])
+
+  const billingPeriod: BillingPeriod = annual ? 'annual' : 'monthly'
+  const planCode = PLAN_CODE[billingPeriod]
+
+  const hasShownRef = useRef(false)
+  useEffect(() => {
+    if (show && !hasShownRef.current) {
+      const countryCode = getMeta('ol-countryCode')
+
+      sendMB('paywall-plans-page-view', {
+        currency,
+        countryCode,
+        version: 'compile-timeout',
+      })
+
+      hasShownRef.current = true
+    } else if (!show) {
+      hasShownRef.current = false
+    }
+  }, [show, currency])
+
+  const handleToggleBilling = useCallback(() => {
+    const nextAnnual = !annual
+    setAnnual(nextAnnual)
+    sendMB('paywall-plans-page-toggle', {
+      'billing-period': nextAnnual ? 'annual' : 'monthly',
+      checked: nextAnnual ? 'checked' : 'unchecked',
+      version: 'compile-timeout',
+    })
+  }, [annual])
+
+  const openCheckout = useCallback(() => {
+    const params = new URLSearchParams({
+      itm_campaign: 'compile-timeout',
+    })
+
+    window.open(
+      `/user/subscription/new?planCode=${planCode}&${params.toString()}`,
+      '_blank',
+      'noopener,noreferrer'
+    )
+  }, [planCode])
+
+  const handleUpgrade = useCallback(() => {
+    sendMB('paywall-plans-page-click', {
+      plan: 'collaborator',
+      'billing-period': billingPeriod,
+      currency,
+      version: 'compile-timeout',
+      button: 'buy',
+    })
+    openCheckout()
+  }, [billingPeriod, currency, openCheckout])
+
+  const viewAllPlansHref = useMemo(() => {
+    const params = new URLSearchParams({
+      itm_campaign: 'compile-timeout',
+    })
+    return `/user/subscription/choose-your-plan?${params.toString()}`
+  }, [])
+
+  const handleViewAllPlans = useCallback(() => {
+    sendMB('paywall-plans-page-click', {
+      button: 'plans',
+      'billing-period': billingPeriod,
+      currency,
+      version: 'compile-timeout',
+    })
+  }, [billingPeriod, currency])
+
+  const price = annual
+    ? standardPlanPricing?.annual
+    : standardPlanPricing?.monthly
+
+  const priceSubtext = annual ? t('per_year') : t('per_month')
+
+  const strikethroughPrice = annual
+    ? standardPlanPricing?.monthlyTimesTwelve
+    : undefined
+
+  return (
+    <OLModal
+      className="compile-time-paywall-modal"
+      size="sm"
+      show={show}
+      onHide={onHide}
+      centered
+    >
+      <OLModalHeader className="border-0 pb-0" closeButton>
+        <div className="w-100 text-center">
+          <OLModalTitle>{t('get_more_compile_time')}</OLModalTitle>
+        </div>
+      </OLModalHeader>
+
+      <OLModalBody>
+        <div className="compile-time-paywall-card">
+          <div className="compile-time-paywall-header">
+            <div className="compile-time-paywall-plan-meta">
+              <p className="compile-time-paywall-plan-label">
+                {t('standard')} {t('plan')}
+              </p>
+              <div className="compile-time-paywall-price-container">
+                {strikethroughPrice ? (
+                  <span className="compile-time-paywall-price-strikethrough">
+                    {strikethroughPrice}
+                  </span>
+                ) : (
+                  <span
+                    className="compile-time-paywall-price-strikethrough"
+                    style={{ visibility: 'hidden' }}
+                    aria-hidden="true"
+                  >
+                    &nbsp;
+                  </span>
+                )}
+                <span className="compile-time-paywall-price">{price}</span>
+                {priceSubtext && (
+                  <span className="compile-time-paywall-price-subtext">
+                    {priceSubtext}
+                  </span>
+                )}
+              </div>
+            </div>
+
+            {supportsAnnualPricing && (
+              <div className="compile-time-paywall-period-toggle">
+                <BillingPeriodToggle
+                  id="compile-timeout-period"
+                  value={annual ? 'annual' : 'monthly'}
+                  onChange={(period: BillingPeriod) => {
+                    if ((period === 'annual') !== annual) {
+                      handleToggleBilling()
+                    }
+                  }}
+                  variant="premium"
+                />
+              </div>
+            )}
+          </div>
+
+          <div className="compile-time-paywall-body">
+            <div className="compile-time-paywall-illustration">
+              <MaterialIcon type="rocket_launch" />
+            </div>
+            <div className="compile-time-paywall-content">
+              <p className="compile-time-paywall-intro">
+                <strong>{t('compile_timeout_modal_intro')}</strong>
+              </p>
+              <div className="compile-time-paywall-feature-list">
+                <ListItem>
+                  {t('collabs_per_proj', { collabcount: 10 })}
+                </ListItem>
+                <ListItem>{t('track_changes')}</ListItem>
+                <ListItem>{t('github_integration')}</ListItem>
+                <ListItem>{t('and_much_more')}</ListItem>
+              </div>
+            </div>
+          </div>
+
+          <div className="compile-time-paywall-actions">
+            <OLButton
+              variant="premium"
+              type="button"
+              size="lg"
+              onClick={handleUpgrade}
+            >
+              {t('buy_now_no_exclamation_mark')}
+            </OLButton>
+
+            <OLButton
+              className="compile-time-paywall-view-plans"
+              variant="ghost"
+              href={viewAllPlansHref}
+              target="_blank"
+              rel="noreferrer"
+              onClick={handleViewAllPlans}
+            >
+              {t('view_all_plans')}
+            </OLButton>
+          </div>
+        </div>
+      </OLModalBody>
+    </OLModal>
+  )
+}
+
+function ListItem({ children }: { children: React.ReactNode }) {
+  return (
+    <div className="compile-time-paywall-list-item">
+      <MaterialIcon type="check_circle" />
+      <span>{children}</span>
+    </div>
+  )
+}
+
+export default CompileTimeoutPaywallModal

+ 31 - 1
services/web/frontend/js/features/pdf-preview/components/timeout-upgrade-prompt-new.tsx

@@ -1,7 +1,7 @@
 import { Trans, useTranslation } from 'react-i18next'
 import { useDetachCompileContext } from '../../../shared/context/detach-compile-context'
 import StartFreeTrialButton from '../../../shared/components/start-free-trial-button'
-import { memo, useCallback, useMemo } from 'react'
+import { memo, useCallback, useMemo, useState } from 'react'
 import PdfLogEntry from './pdf-log-entry'
 import { useStopOnFirstError } from '../../../shared/hooks/use-stop-on-first-error'
 import OLButton from '@/shared/components/ol/ol-button'
@@ -13,6 +13,7 @@ import {
   useIsNewEditorEnabled,
 } from '@/features/ide-redesign/utils/new-editor-utils'
 import { getSplitTestVariant, isSplitTestEnabled } from '@/utils/splitTestUtils'
+import CompileTimeoutPaywallModal from '@/features/pdf-preview/components/compile-timeout-paywall-modal'
 
 function TimeoutUpgradePromptNew() {
   const {
@@ -26,6 +27,13 @@ function TimeoutUpgradePromptNew() {
     'compile-timeout-remove-info'
   )
 
+  const isCompileTimeoutTargetPlansEnabled = isSplitTestEnabled(
+    'compile-timeout-target-plans'
+  )
+
+  const [showCompileTimeoutPaywall, setShowCompileTimeoutPaywall] =
+    useState(false)
+
   const { enableStopOnFirstError } = useStopOnFirstError({
     eventSource: 'timeout-new',
   })
@@ -56,6 +64,8 @@ function TimeoutUpgradePromptNew() {
       <CompileTimeout
         isProjectOwner={isProjectOwner}
         segmentation={sharedSegmentation}
+        onShowPaywallModal={() => setShowCompileTimeoutPaywall(true)}
+        isCompileTimeoutTargetPlansEnabled={isCompileTimeoutTargetPlansEnabled}
       />
       {getMeta('ol-ExposedSettings').enableSubscriptions &&
         !shouldHideCompileTimeoutInfo && (
@@ -66,6 +76,10 @@ function TimeoutUpgradePromptNew() {
             lastCompileOptions={lastCompileOptions}
           />
         )}
+      <CompileTimeoutPaywallModal
+        show={showCompileTimeoutPaywall}
+        onHide={() => setShowCompileTimeoutPaywall(false)}
+      />
     </>
   )
 }
@@ -73,11 +87,15 @@ function TimeoutUpgradePromptNew() {
 type CompileTimeoutProps = {
   isProjectOwner: boolean
   segmentation: eventTracking.Segmentation
+  onShowPaywallModal: () => void
+  isCompileTimeoutTargetPlansEnabled: boolean
 }
 
 const CompileTimeout = memo(function CompileTimeout({
   isProjectOwner,
   segmentation,
+  onShowPaywallModal,
+  isCompileTimeoutTargetPlansEnabled,
 }: CompileTimeoutProps) {
   const { t } = useTranslation()
   const extraSearchParams = useMemo(() => {
@@ -96,6 +114,17 @@ const CompileTimeout = memo(function CompileTimeout({
     }
   }, [])
 
+  const handleFreeTrialClick = useCallback(
+    (event: React.MouseEvent<HTMLButtonElement>) => {
+      if (isCompileTimeoutTargetPlansEnabled) {
+        event.preventDefault()
+        event.stopPropagation()
+        onShowPaywallModal()
+      }
+    },
+    [isCompileTimeoutTargetPlansEnabled, onShowPaywallModal]
+  )
+
   return (
     <PdfLogEntry
       autoExpand
@@ -132,6 +161,7 @@ const CompileTimeout = memo(function CompileTimeout({
                   buttonProps={{ variant: 'primary', className: 'w-100' }}
                   segmentation={segmentation}
                   extraSearchParams={extraSearchParams}
+                  handleClick={handleFreeTrialClick}
                 >
                   {t('start_a_free_trial')}
                 </StartFreeTrialButton>

+ 66 - 0
services/web/frontend/js/shared/components/billing-period-toggle.tsx

@@ -0,0 +1,66 @@
+import { useTranslation } from 'react-i18next'
+import '../../../stylesheets/components/billing-period-toggle.scss'
+
+export type BillingPeriod = 'monthly' | 'annual'
+
+export type BillingPeriodToggleProps = {
+  value: BillingPeriod
+  onChange: (period: BillingPeriod) => void
+  id?: string
+  showDiscount?: boolean
+  variant?: 'default' | 'premium'
+}
+
+export function BillingPeriodToggle({
+  value,
+  onChange,
+  id = 'billing-period',
+  showDiscount = true,
+  variant = 'default',
+}: BillingPeriodToggleProps) {
+  const { t } = useTranslation()
+
+  return (
+    <fieldset
+      className={`billing-period-toggle ${variant === 'premium' ? 'billing-period-toggle-premium' : ''}`}
+    >
+      <legend className="visually-hidden">
+        {t('billing_period_sentence_case')}
+      </legend>
+      <input
+        type="radio"
+        id={`${id}-annual`}
+        name={id}
+        checked={value === 'annual'}
+        onChange={() => {
+          if (value !== 'annual') {
+            onChange('annual')
+          }
+        }}
+      />
+      <label htmlFor={`${id}-annual`}>
+        {t('yearly')}
+        {showDiscount && (
+          <span className="billing-period-toggle-discount-badge">
+            {t('save_20_percent')}
+          </span>
+        )}
+      </label>
+
+      <input
+        type="radio"
+        id={`${id}-monthly`}
+        name={id}
+        checked={value === 'monthly'}
+        onChange={() => {
+          if (value !== 'monthly') {
+            onChange('monthly')
+          }
+        }}
+      />
+      <label htmlFor={`${id}-monthly`}>{t('monthly')}</label>
+    </fieldset>
+  )
+}
+
+export default BillingPeriodToggle

+ 3 - 0
services/web/frontend/js/shared/components/start-free-trial-button.tsx

@@ -44,6 +44,9 @@ export default function StartFreeTrialButton({
 
       if (handleClick) {
         handleClick(event)
+        if (event.isPropagationStopped()) {
+          return
+        }
       }
 
       startFreeTrial(source, variant, segmentation, extraSearchParams)

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

@@ -270,6 +270,11 @@ export interface Meta {
   'ol-ssoDisabled': boolean
   'ol-ssoErrorMessage': string
   'ol-ssoInitPath': string
+  'ol-standardPlanPricing': {
+    monthly?: string
+    annual?: string
+    monthlyTimesTwelve?: string
+  }
   'ol-stripeAccountId': string
   'ol-stripePublicKeyUK': string
   'ol-stripePublicKeyUS': string

+ 84 - 0
services/web/frontend/stories/shared/billing-period-toggle.stories.tsx

@@ -0,0 +1,84 @@
+import type { Meta, StoryObj } from '@storybook/react'
+import { useState } from 'react'
+import BillingPeriodToggle, {
+  type BillingPeriod,
+} from '../../js/shared/components/billing-period-toggle'
+
+type Args = React.ComponentProps<typeof BillingPeriodToggle>
+
+const meta: Meta<Args> = {
+  title: 'Subscription / Billing Period Toggle',
+  component: BillingPeriodToggle,
+  parameters: {
+    controls: {
+      include: ['value', 'showDiscount', 'variant'],
+    },
+  },
+  argTypes: {
+    value: {
+      control: 'radio',
+      options: ['monthly', 'annual'],
+    },
+    showDiscount: {
+      control: 'boolean',
+    },
+    variant: {
+      control: 'radio',
+      options: ['default', 'premium'],
+    },
+    onChange: { action: 'onChange' },
+  },
+  args: {
+    value: 'monthly',
+    showDiscount: true,
+    variant: 'default',
+  },
+}
+
+export default meta
+
+type Story = StoryObj<Args>
+
+const InteractiveToggle = (args: Args) => {
+  const [value, setValue] = useState<BillingPeriod>(args.value)
+  return (
+    <BillingPeriodToggle
+      {...args}
+      value={value}
+      onChange={period => {
+        setValue(period)
+        args.onChange?.(period)
+      }}
+    />
+  )
+}
+
+export const Default: Story = {
+  render: InteractiveToggle,
+}
+
+export const WithoutDiscount: Story = {
+  render: args => <InteractiveToggle {...args} showDiscount={false} />,
+}
+
+export const AnnualSelected: Story = {
+  args: {
+    value: 'annual',
+  },
+  render: InteractiveToggle,
+}
+
+export const PremiumVariant: Story = {
+  args: {
+    variant: 'premium',
+  },
+  render: InteractiveToggle,
+}
+
+export const PremiumVariantAnnualSelected: Story = {
+  args: {
+    variant: 'premium',
+    value: 'annual',
+  },
+  render: InteractiveToggle,
+}

+ 84 - 0
services/web/frontend/stylesheets/components/billing-period-toggle.scss

@@ -0,0 +1,84 @@
+@import '../foundations/all';
+@import '../abstracts/mixins';
+
+.billing-period-toggle {
+  position: relative;
+  display: inline-flex;
+  background-color: var(--bg-light-secondary);
+  border-radius: var(--border-radius-full);
+  padding: var(--spacing-03);
+  gap: var(--spacing-04);
+  border: none;
+
+  label {
+    display: inline-flex;
+    align-items: center;
+    margin: 0;
+    font-size: var(--font-size-05);
+    font-weight: 600;
+    line-height: var(--line-height-04);
+    text-align: center;
+    padding: var(--spacing-01) var(--spacing-04);
+    border-radius: var(--border-radius-full);
+
+    &:hover {
+      background-color: var(--neutral-20);
+      cursor: pointer;
+    }
+  }
+
+  input[type='radio'] {
+    position: absolute;
+    left: -9999px;
+
+    &:focus,
+    &:focus-visible {
+      outline: 0;
+    }
+  }
+
+  input[type='radio']:focus-visible + label,
+  input[type='radio']:checked:focus-visible + label {
+    box-shadow: 0 0 0 2px var(--blue-30);
+    outline: 2px solid transparent;
+  }
+
+  input[type='radio']:checked + label {
+    background-color: var(--green-50);
+    color: white;
+    /* stylelint-disable-next-line length-zero-no-unit, color-function-notation, alpha-value-notation */
+    box-shadow: 0px 2px 4px 0px rgba(30, 37, 48, 0.16);
+
+    .billing-period-toggle-discount-badge {
+      background-color: var(--green-10);
+      color: var(--green-60);
+    }
+  }
+
+  &.billing-period-toggle-premium {
+    input[type='radio']:checked + label {
+      background: var(--premium-gradient);
+      color: white;
+
+      .billing-period-toggle-discount-badge {
+        background-color: var(--green-10);
+        color: var(--blue-70);
+      }
+    }
+  }
+}
+
+.billing-period-toggle-discount-badge {
+  font-size: var(--font-size-01);
+  font-family: 'DM Mono', monospace;
+  padding: 2px 8px;
+  height: 20px;
+  border-radius: 10px;
+  background-color: var(--neutral-70);
+  color: white;
+  display: flex;
+  align-items: center;
+  font-weight: 500;
+  line-height: var(--line-height-01);
+  margin-left: var(--spacing-03);
+}

+ 144 - 0
services/web/frontend/stylesheets/components/compile-time-paywall-modal.scss

@@ -0,0 +1,144 @@
+@import '../foundations/all';
+@import '../abstracts/mixins';
+
+.compile-time-paywall-modal {
+  .modal-dialog {
+    max-width: 600px;
+  }
+
+  .modal-header .modal-title {
+    margin: var(--spacing-09) 0 var(--spacing-06);
+    font-weight: 600;
+    font-size: var(--font-size-08);
+    text-align: center;
+    line-height: var(--line-height-07);
+  }
+
+  .compile-time-paywall-card {
+    display: flex;
+    flex-direction: column;
+    gap: var(--spacing-07);
+    padding: var(--spacing-08);
+    border: 2px solid transparent;
+    border-radius: var(--border-radius-medium);
+    background:
+      linear-gradient(var(--bg-light-primary), var(--bg-light-primary))
+        padding-box,
+      var(--premium-gradient) border-box;
+  }
+
+  .compile-time-paywall-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: flex-start;
+    gap: var(--spacing-09);
+  }
+
+  .compile-time-paywall-period-toggle {
+    margin-top: -8px;
+  }
+
+  .compile-time-paywall-plan-meta,
+  .compile-time-paywall-price-container,
+  .compile-time-paywall-feature-list,
+  .compile-time-paywall-actions {
+    display: flex;
+    flex-direction: column;
+  }
+
+  .compile-time-paywall-plan-meta {
+    flex: 1;
+  }
+
+  .compile-time-paywall-plan-label {
+    font-size: var(--font-size-05);
+    font-weight: 600;
+    color: var(--content-secondary);
+    margin: 0 0 var(--spacing-05);
+  }
+
+  .compile-time-paywall-price-container {
+    align-items: flex-start;
+  }
+
+  .compile-time-paywall-price {
+    font-weight: 600;
+    font-size: var(--font-size-08);
+    line-height: var(--line-height-07);
+  }
+
+  .compile-time-paywall-price-strikethrough {
+    font-weight: 600;
+    font-size: var(--font-size-06);
+    line-height: var(--line-height-05);
+    color: var(--neutral-60);
+    text-decoration: line-through;
+  }
+
+  .compile-time-paywall-price-subtext {
+    font-size: var(--font-size-02);
+    color: var(--content-secondary);
+    line-height: var(--line-height-02);
+  }
+
+  .compile-time-paywall-body {
+    display: flex;
+    gap: var(--spacing-09);
+    align-items: flex-start;
+    justify-content: space-between;
+  }
+
+  .compile-time-paywall-illustration {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    flex: 1;
+    flex-shrink: 0;
+
+    @include premium-text;
+  }
+
+  .compile-time-paywall-illustration .material-symbols {
+    font-size: 150px;
+  }
+
+  .compile-time-paywall-content {
+    flex: 1.2;
+  }
+
+  .compile-time-paywall-intro {
+    font-size: var(--font-size-03);
+    line-height: var(--line-height-03);
+    margin-bottom: var(--spacing-04);
+  }
+
+  .compile-time-paywall-feature-list {
+    gap: var(--spacing-04);
+  }
+
+  .compile-time-paywall-list-item {
+    display: flex;
+    align-items: flex-start;
+    gap: var(--spacing-05);
+    font-size: var(--font-size-02);
+    line-height: var(--line-height-02);
+  }
+
+  .compile-time-paywall-list-item .material-symbols {
+    font-size: var(--font-size-04);
+    flex-shrink: 0;
+
+    @include premium-text;
+  }
+
+  .compile-time-paywall-actions {
+    align-items: stretch;
+    gap: var(--spacing-03);
+  }
+
+  .compile-time-paywall-view-plans {
+    font-size: var(--font-size-02);
+    text-decoration: none;
+    font-weight: 600;
+  }
+}

+ 5 - 0
services/web/locales/en.json

@@ -170,6 +170,7 @@
   "an_error_occured_while_restoring_project": "An error occured while restoring the project",
   "an_error_occurred_when_verifying_the_coupon_code": "An error occurred when verifying the coupon code",
   "and": "and",
+  "and_much_more": "and much more",
   "annual": "Annual",
   "annual_discount": "Annual discount",
   "anonymous": "Anonymous",
@@ -394,6 +395,7 @@
   "compile_servers": "Compile servers",
   "compile_servers_info_new": "The servers used to compile your project. Compiles for users on paid plans always run on the fastest available servers.",
   "compile_terminated_by_user": "The compile was cancelled using the ‘Stop compilation’ button. You can download the raw logs to see where the compile stopped.",
+  "compile_timeout_modal_intro": "24x compile time on the fastest servers, plus...",
   "compile_timeout_short": "Compile timeout",
   "compile_timeout_short_info_new": "This is how much time you get to compile your project on Overleaf. You may need additional time for longer or more complex projects.",
   "compiler": "Compiler",
@@ -863,6 +865,7 @@
   "get_in_touch": "Get in touch",
   "get_in_touch_having_problems": "<a href=\"__link__\">Get in touch with support</a> if you’re having problems",
   "get_involved": "Get involved",
+  "get_more_compile_time": "Get more compile time",
   "get_most_subscription_by_checking_ai_writefull": "Get the most out of your subscription by checking out <0>Overleaf’s AI features</0> and <1>Writefull’s features</1>.",
   "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>.",
@@ -895,6 +898,7 @@
   "github_file_sync_error": "We are unable to sync the following files:",
   "github_git_and_dropbox_integrations": "<0>Github</0>, <0>Git</0> and <0>Dropbox</0> integrations",
   "github_git_folder_error": "This project contains a .git folder at the top level, indicating that it is already a git repository. The Overleaf GitHub sync service cannot sync git histories. Please remove the .git folder and try again.",
+  "github_integration": "GitHub integration",
   "github_integration_lowercase": "Git and GitHub integration",
   "github_is_no_longer_connected": "GitHub is no longer connected to this project.",
   "github_is_premium": "GitHub Sync is a premium feature",
@@ -2632,6 +2636,7 @@
   "verify_your_email_address": "Verify your email address",
   "view": "View",
   "view_all": "View all",
+  "view_all_plans": "View all plans",
   "view_audit_logs_group_subtext": "View and download audit logs for your group subscription",
   "view_billing_details": "View billing details",
   "view_code": "View code",

+ 11 - 0
services/web/test/unit/src/Project/ProjectController.test.mjs

@@ -31,6 +31,16 @@ describe('ProjectController', function () {
       algolia: {},
       plans: [],
       features: {},
+      localizedPlanPricing: {
+        USD: {
+          collaborator: {
+            monthly: 15,
+            annual: 180,
+            annualDividedByTwelve: 15,
+            monthlyTimesTwelve: 180,
+          },
+        },
+      },
     }
     ctx.brandVariationDetails = {
       id: '12',
@@ -64,6 +74,7 @@ describe('ProjectController', function () {
       },
     }
     ctx.SubscriptionController = {
+      getRecommendedCurrency: sinon.stub().resolves({ currency: 'USD' }),
       promises: {
         getRecommendedCurrency: sinon.stub().resolves({ currency: 'USD' }),
       },