diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs
index 8bac8835b9..f2c607d580 100644
--- a/services/web/app/src/Features/Project/ProjectController.mjs
+++ b/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,
}
diff --git a/services/web/app/views/project/editor/_meta.pug b/services/web/app/views/project/editor/_meta.pug
index 5f44c487b2..d1d871679a 100644
--- a/services/web/app/views/project/editor/_meta.pug
+++ b/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"))
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index a22b755a5a..60433dcc38 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/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": "",
diff --git a/services/web/frontend/js/features/pdf-preview/components/compile-timeout-paywall-modal.tsx b/services/web/frontend/js/features/pdf-preview/components/compile-timeout-paywall-modal.tsx
new file mode 100644
index 0000000000..b0b2ec549c
--- /dev/null
+++ b/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 (
+
+
+
+
{t('get_more_compile_time')}
+
+
+
+
+
+
+
+
+ {t('standard')} {t('plan')}
+
+
+ {strikethroughPrice ? (
+
+ {strikethroughPrice}
+
+ ) : (
+
+
+
+ )}
+ {price}
+ {priceSubtext && (
+
+ {priceSubtext}
+
+ )}
+
+
+
+ {supportsAnnualPricing && (
+
+ {
+ if ((period === 'annual') !== annual) {
+ handleToggleBilling()
+ }
+ }}
+ variant="premium"
+ />
+
+ )}
+
+
+
+
+
+
+
+
+ {t('compile_timeout_modal_intro')}
+
+
+
+ {t('collabs_per_proj', { collabcount: 10 })}
+
+ {t('track_changes')}
+ {t('github_integration')}
+ {t('and_much_more')}
+
+
+
+
+
+
+ {t('buy_now_no_exclamation_mark')}
+
+
+
+ {t('view_all_plans')}
+
+
+
+
+
+ )
+}
+
+function ListItem({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+ )
+}
+
+export default CompileTimeoutPaywallModal
diff --git a/services/web/frontend/js/features/pdf-preview/components/timeout-upgrade-prompt-new.tsx b/services/web/frontend/js/features/pdf-preview/components/timeout-upgrade-prompt-new.tsx
index b2a49c21c8..b279f1ca84 100644
--- a/services/web/frontend/js/features/pdf-preview/components/timeout-upgrade-prompt-new.tsx
+++ b/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() {
setShowCompileTimeoutPaywall(true)}
+ isCompileTimeoutTargetPlansEnabled={isCompileTimeoutTargetPlansEnabled}
/>
{getMeta('ol-ExposedSettings').enableSubscriptions &&
!shouldHideCompileTimeoutInfo && (
@@ -66,6 +76,10 @@ function TimeoutUpgradePromptNew() {
lastCompileOptions={lastCompileOptions}
/>
)}
+ 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) => {
+ if (isCompileTimeoutTargetPlansEnabled) {
+ event.preventDefault()
+ event.stopPropagation()
+ onShowPaywallModal()
+ }
+ },
+ [isCompileTimeoutTargetPlansEnabled, onShowPaywallModal]
+ )
+
return (
{t('start_a_free_trial')}
diff --git a/services/web/frontend/js/shared/components/billing-period-toggle.tsx b/services/web/frontend/js/shared/components/billing-period-toggle.tsx
new file mode 100644
index 0000000000..0cc468f349
--- /dev/null
+++ b/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 (
+
+ )
+}
+
+export default BillingPeriodToggle
diff --git a/services/web/frontend/js/shared/components/start-free-trial-button.tsx b/services/web/frontend/js/shared/components/start-free-trial-button.tsx
index 0226ced4d8..d035bd43f1 100644
--- a/services/web/frontend/js/shared/components/start-free-trial-button.tsx
+++ b/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)
diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts
index 704dfe5347..fd67855a8b 100644
--- a/services/web/frontend/js/utils/meta.ts
+++ b/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
diff --git a/services/web/frontend/stories/shared/billing-period-toggle.stories.tsx b/services/web/frontend/stories/shared/billing-period-toggle.stories.tsx
new file mode 100644
index 0000000000..ea773e2554
--- /dev/null
+++ b/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
+
+const meta: Meta = {
+ 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
+
+const InteractiveToggle = (args: Args) => {
+ const [value, setValue] = useState(args.value)
+ return (
+ {
+ setValue(period)
+ args.onChange?.(period)
+ }}
+ />
+ )
+}
+
+export const Default: Story = {
+ render: InteractiveToggle,
+}
+
+export const WithoutDiscount: Story = {
+ render: args => ,
+}
+
+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,
+}
diff --git a/services/web/frontend/stylesheets/components/billing-period-toggle.scss b/services/web/frontend/stylesheets/components/billing-period-toggle.scss
new file mode 100644
index 0000000000..b2809b139c
--- /dev/null
+++ b/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);
+}
diff --git a/services/web/frontend/stylesheets/components/compile-time-paywall-modal.scss b/services/web/frontend/stylesheets/components/compile-time-paywall-modal.scss
new file mode 100644
index 0000000000..60e2d1648a
--- /dev/null
+++ b/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;
+ }
+}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index ef9cd23888..0df1e6fc7a 100644
--- a/services/web/locales/en.json
+++ b/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": "Get in touch with support 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 features0> and <1>Writefull’s features1>.",
"get_most_subscription_by_checking_overleaf": "Get the most out of your subscription by checking out <0>Overleaf’s features0>.",
"get_most_subscription_by_checking_overleaf_ai_writefull": "Get the most out of your subscription by checking out <0>Overleaf’s features0>, <1>Overleaf’s AI features1> and <2>Writefull’s features2>.",
@@ -895,6 +898,7 @@
"github_file_sync_error": "We are unable to sync the following files:",
"github_git_and_dropbox_integrations": "<0>Github0>, <0>Git0> and <0>Dropbox0> 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",
diff --git a/services/web/test/unit/src/Project/ProjectController.test.mjs b/services/web/test/unit/src/Project/ProjectController.test.mjs
index 4bd364fd69..553b45bb58 100644
--- a/services/web/test/unit/src/Project/ProjectController.test.mjs
+++ b/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' }),
},