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

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

GitOrigin-RevId: b352cb239742aa7ffbef7f3cd5c65ac719569ebf
This commit is contained in:
roo hutton
2025-11-06 09:06:29 +00:00
committed by Copybot
parent faceb90593
commit 2e11f2c7b7
13 changed files with 738 additions and 53 deletions
@@ -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,
}
@@ -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"))
@@ -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": "",
@@ -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
@@ -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>
@@ -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
@@ -44,6 +44,9 @@ export default function StartFreeTrialButton({
if (handleClick) {
handleClick(event)
if (event.isPropagationStopped()) {
return
}
}
startFreeTrial(source, variant, segmentation, extraSearchParams)
+5
View File
@@ -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
@@ -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,
}
@@ -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);
}
@@ -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
View File
@@ -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 youre 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>Overleafs AI features</0> and <1>Writefulls features</1>.",
"get_most_subscription_by_checking_overleaf": "Get the most out of your subscription by checking out <0>Overleafs features</0>.",
"get_most_subscription_by_checking_overleaf_ai_writefull": "Get the most out of your subscription by checking out <0>Overleafs features</0>, <1>Overleafs AI features</1> and <2>Writefulls features</2>.",
@@ -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",
@@ -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' }),
},